examples/storefront Public
Store product images in R2 and serve them through the API
The merchant uploads an image with PUT /admin/products/:id/image. It is stored under a key that ends in a hash of its bytes, so a new image gets a new URL and every image URL can be cached for a year. GET /images/* streams the object back and answers If-None-Match with a 304.
Changed files
7 files changed188 additions4 deletionsagainst f13d917
Changedsrc/app.ts+9−0
| @@ -5,6 +5,7 @@ | ||
| import { getProduct, listProducts, parseProductInput, upsertProduct } from "./catalog.ts"; | ||
| import type { Env } from "./env.ts"; | ||
| import { errorResponse, HttpError, json, readJson, requireAdmin } from "./http.ts"; | ||
| +import { putProductImage, serveImage } from "./images.ts"; | ||
| import { Router } from "./router.ts"; | ||
| export type Deps = { | ||
| @@ -30,6 +31,14 @@ | ||
| const input = parseProductInput(p.id!, await readJson(req)); | ||
| return json({ product: await upsertProduct(env.DB, input, deps.now()) }); | ||
| }); | ||
| + | ||
| + r.on("PUT", "/admin/products/:id/image", async (req, p) => { | ||
| + requireAdmin(req, env.ADMIN_TOKEN); | ||
| + await putProductImage(env.DB, env.IMAGES, p.id!, req, deps.now()); | ||
| + return json({ product: await getProduct(env.DB, p.id!) }); | ||
| + }); | ||
| + | ||
| + r.on("GET", "/images/*", (req, p) => serveImage(env.IMAGES, p.rest!, req)); | ||
| r.on("POST", "/carts", async () => json({ cart: await createCart(env.DB, deps.newId(), deps.now()) }, 201)); | ||
Changedsrc/env.ts+1−0
| @@ -1,5 +1,6 @@ | ||
| // The bindings and secrets of the storefront-api Worker (wrangler.jsonc). | ||
| export interface Env { | ||
| DB: D1Database; | ||
| + IMAGES: R2Bucket; | ||
| ADMIN_TOKEN: string; | ||
| } | ||
Addedsrc/images.ts+57−0
| @@ -0,0 +1,57 @@ | ||
| +// Product images. The merchant uploads one image per product and it is stored in R2 under a key | ||
| +// that ends in a hash of its bytes, so a new image gets a new URL and every URL can be cached | ||
| +// for a year. | ||
| + | ||
| +import { HttpError } from "./http.ts"; | ||
| + | ||
| +export const MAX_IMAGE_BYTES = 5 * 1024 * 1024; | ||
| +export const IMMUTABLE = "public, max-age=31536000, immutable"; | ||
| + | ||
| +const EXTENSIONS: Record<string, string> = { | ||
| + "image/jpeg": "jpg", | ||
| + "image/png": "png", | ||
| + "image/webp": "webp", | ||
| + "image/avif": "avif", | ||
| +}; | ||
| + | ||
| +// Stores the request body as the product's image and points the product at it. The previous | ||
| +// object stays in the bucket: carts and caches may still hold its URL. | ||
| +export async function putProductImage(db: D1Database, bucket: R2Bucket, productId: string, req: Request, now: number): Promise<string> { | ||
| + const exists = await db.prepare("SELECT 1 AS one FROM products WHERE id = ?").bind(productId).first<number>("one"); | ||
| + if (!exists) throw new HttpError(404, "product_not_found", "No such product."); | ||
| + | ||
| + const type = (req.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase(); | ||
| + const ext = EXTENSIONS[type]; | ||
| + if (!ext) throw new HttpError(415, "unsupported_image", "Send a JPEG, PNG, WebP or AVIF image."); | ||
| + if (Number(req.headers.get("content-length") ?? 0) > MAX_IMAGE_BYTES) throw tooLarge(); | ||
| + const bytes = await req.arrayBuffer(); | ||
| + if (bytes.byteLength === 0) throw new HttpError(400, "empty_image", "The image is empty."); | ||
| + if (bytes.byteLength > MAX_IMAGE_BYTES) throw tooLarge(); | ||
| + | ||
| + const key = `products/${productId}/${await shortHash(bytes)}.${ext}`; | ||
| + await bucket.put(key, bytes, { httpMetadata: { contentType: type, cacheControl: IMMUTABLE } }); | ||
| + await db.prepare("UPDATE products SET image_key = ?, updated_at = ? WHERE id = ?").bind(key, now, productId).run(); | ||
| + return key; | ||
| +} | ||
| + | ||
| +// GET /images/<key>: the object from R2 with its stored headers. A matching If-None-Match gets | ||
| +// a 304 so browsers revalidate without downloading the image again. | ||
| +export async function serveImage(bucket: R2Bucket, key: string, req: Request): Promise<Response> { | ||
| + if (!key.startsWith("products/")) throw new HttpError(404, "not_found", "No such image."); | ||
| + const object = await bucket.get(key); | ||
| + if (!object) throw new HttpError(404, "not_found", "No such image."); | ||
| + const headers = new Headers(); | ||
| + object.writeHttpMetadata(headers); | ||
| + headers.set("etag", object.httpEtag); | ||
| + if (req.headers.get("if-none-match") === object.httpEtag) return new Response(null, { status: 304, headers }); | ||
| + return new Response(object.body, { headers }); | ||
| +} | ||
| + | ||
| +async function shortHash(bytes: ArrayBuffer): Promise<string> { | ||
| + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); | ||
| + return [...digest.slice(0, 8)].map((b) => b.toString(16).padStart(2, "0")).join(""); | ||
| +} | ||
| + | ||
| +function tooLarge(): HttpError { | ||
| + return new HttpError(413, "image_too_large", "An image is at most 5 MiB."); | ||
| +} | ||
Changedtest/helpers/app.ts+7−3
| @@ -3,24 +3,28 @@ | ||
| import { createApp } from "../../src/app.ts"; | ||
| import type { Env } from "../../src/env.ts"; | ||
| import { testDb } from "./d1.ts"; | ||
| +import { testBucket } from "./r2.ts"; | ||
| export const ADMIN_TOKEN = "test-admin-token"; | ||
| export const NOW = Date.UTC(2026, 9, 1, 12, 0, 0); | ||
| export function setup() { | ||
| - const env: Env = { DB: testDb(), ADMIN_TOKEN }; | ||
| + const env: Env = { DB: testDb(), IMAGES: testBucket(), ADMIN_TOKEN }; | ||
| let n = 0; | ||
| const app = createApp(env, { now: () => NOW, newId: () => `id-${++n}` }); | ||
| async function call(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) { | ||
| const init: RequestInit = { method, headers: { ...headers } }; | ||
| - if (body !== undefined) { | ||
| + if (body instanceof Uint8Array) { | ||
| + init.body = body; | ||
| + } else if (body !== undefined) { | ||
| init.body = JSON.stringify(body); | ||
| (init.headers as Record<string, string>)["content-type"] = "application/json"; | ||
| } | ||
| const res = await app.fetch(new Request(`https://shop.test${path}`, init)); | ||
| const text = await res.text(); | ||
| - return { status: res.status, headers: res.headers, body: text ? JSON.parse(text) : null }; | ||
| + const isJson = (res.headers.get("content-type") ?? "").startsWith("application/json"); | ||
| + return { status: res.status, headers: res.headers, body: isJson && text ? JSON.parse(text) : null, text }; | ||
| } | ||
| const admin = { authorization: `Bearer ${ADMIN_TOKEN}` }; | ||
Addedtest/helpers/r2.ts+51−0
| @@ -0,0 +1,51 @@ | ||
| +// An R2 stand-in kept in memory: put, get, head, delete, and the parts of R2Object the | ||
| +// storefront reads (body, httpEtag, httpMetadata, writeHttpMetadata). | ||
| + | ||
| +type Stored = { bytes: Uint8Array; httpMetadata: R2HTTPMetadata; etag: string }; | ||
| + | ||
| +export class TestR2 { | ||
| + readonly objects = new Map<string, Stored>(); | ||
| + | ||
| + async put(key: string, value: ArrayBuffer | ArrayBufferView | string, options: R2PutOptions = {}) { | ||
| + const bytes = typeof value === "string" ? new TextEncoder().encode(value) : new Uint8Array(ArrayBuffer.isView(value) ? value.buffer : value); | ||
| + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); | ||
| + const etag = [...digest.slice(0, 16)].map((b) => b.toString(16).padStart(2, "0")).join(""); | ||
| + this.objects.set(key, { bytes, httpMetadata: (options.httpMetadata ?? {}) as R2HTTPMetadata, etag }); | ||
| + return this.view(key)!; | ||
| + } | ||
| + | ||
| + async head(key: string) { | ||
| + return this.view(key); | ||
| + } | ||
| + | ||
| + async get(key: string) { | ||
| + const v = this.view(key); | ||
| + if (!v) return null; | ||
| + const bytes = this.objects.get(key)!.bytes; | ||
| + return { ...v, body: new Blob([bytes]).stream() }; | ||
| + } | ||
| + | ||
| + async delete(keys: string | string[]) { | ||
| + for (const k of Array.isArray(keys) ? keys : [keys]) this.objects.delete(k); | ||
| + } | ||
| + | ||
| + private view(key: string) { | ||
| + const o = this.objects.get(key); | ||
| + if (!o) return null; | ||
| + return { | ||
| + key, | ||
| + size: o.bytes.byteLength, | ||
| + etag: o.etag, | ||
| + httpEtag: `"${o.etag}"`, | ||
| + httpMetadata: o.httpMetadata, | ||
| + writeHttpMetadata(headers: Headers) { | ||
| + if (o.httpMetadata.contentType) headers.set("content-type", o.httpMetadata.contentType); | ||
| + if (o.httpMetadata.cacheControl) headers.set("cache-control", o.httpMetadata.cacheControl); | ||
| + }, | ||
| + }; | ||
| + } | ||
| +} | ||
| + | ||
| +export function testBucket(): R2Bucket { | ||
| + return new TestR2() as unknown as R2Bucket; | ||
| +} | ||
Addedtest/images.test.ts+61−0
| @@ -0,0 +1,61 @@ | ||
| +import assert from "node:assert/strict"; | ||
| +import { test } from "node:test"; | ||
| +import { setup } from "./helpers/app.ts"; | ||
| + | ||
| +const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]); | ||
| + | ||
| +async function upload(s: ReturnType<typeof setup>, id: string, bytes: Uint8Array, type = "image/png") { | ||
| + return s.call("PUT", `/admin/products/${id}/image`, bytes, { ...s.admin, "content-type": type }); | ||
| +} | ||
| + | ||
| +test("an uploaded image is stored under a content hash and linked from the product", async () => { | ||
| + const s = setup(); | ||
| + await s.addProduct("wool-socks"); | ||
| + const res = await upload(s, "wool-socks", PNG); | ||
| + assert.equal(res.status, 200); | ||
| + assert.match(res.body.product.image_url, /^\/images\/products\/wool-socks\/[0-9a-f]{16}\.png$/); | ||
| + | ||
| + const img = await s.call("GET", res.body.product.image_url); | ||
| + assert.equal(img.status, 200); | ||
| + assert.equal(img.headers.get("content-type"), "image/png"); | ||
| + assert.equal(img.headers.get("cache-control"), "public, max-age=31536000, immutable"); | ||
| +}); | ||
| + | ||
| +test("a new image gets a new URL", async () => { | ||
| + const s = setup(); | ||
| + await s.addProduct("wool-socks"); | ||
| + const first = (await upload(s, "wool-socks", PNG)).body.product.image_url; | ||
| + const second = (await upload(s, "wool-socks", new Uint8Array([...PNG, 9]))).body.product.image_url; | ||
| + assert.notEqual(first, second); | ||
| + assert.equal((await s.call("GET", first)).status, 200, "the old URL keeps working"); | ||
| +}); | ||
| + | ||
| +test("answers 304 when the browser already has the image", async () => { | ||
| + const s = setup(); | ||
| + await s.addProduct("wool-socks"); | ||
| + const url = (await upload(s, "wool-socks", PNG)).body.product.image_url; | ||
| + const etag = (await s.call("GET", url)).headers.get("etag")!; | ||
| + assert.equal((await s.call("GET", url, undefined, { "if-none-match": etag })).status, 304); | ||
| +}); | ||
| + | ||
| +test("refuses types that are not images and images over 5 MiB", async () => { | ||
| + const s = setup(); | ||
| + await s.addProduct("wool-socks"); | ||
| + assert.equal((await upload(s, "wool-socks", PNG, "text/html")).status, 415); | ||
| + assert.equal((await upload(s, "wool-socks", new Uint8Array(5 * 1024 * 1024 + 1))).status, 413); | ||
| + assert.equal((await upload(s, "wool-socks", new Uint8Array(0))).status, 400); | ||
| +}); | ||
| + | ||
| +test("uploading needs the admin token and an existing product", async () => { | ||
| + const s = setup(); | ||
| + assert.equal((await upload(s, "nope", PNG)).body.error, "product_not_found"); | ||
| + await s.addProduct("wool-socks"); | ||
| + const res = await s.call("PUT", "/admin/products/wool-socks/image", PNG, { "content-type": "image/png" }); | ||
| + assert.equal(res.status, 401); | ||
| +}); | ||
| + | ||
| +test("only product images are served", async () => { | ||
| + const s = setup(); | ||
| + assert.equal((await s.call("GET", "/images/products/none.png")).status, 404); | ||
| + assert.equal((await s.call("GET", "/images/private/report.pdf")).status, 404); | ||
| +}); | ||
Changedwrangler.jsonc+2−1
| @@ -13,5 +13,6 @@ | ||
| "database_id": "", | ||
| "migrations_dir": "migrations" | ||
| } | ||
| - ] | ||
| + ], | ||
| + "r2_buckets": [{ "binding": "IMAGES", "bucket_name": "storefront-images" }] | ||
| } | ||
Checks
No checks reported for this commit.
Runs
No runs for this commit.