Uploading to S3 with presigned URLs
Optiq never talks to AWS. Your backend authenticates the user, decides the object key, and returns a short-lived presigned URL; the browser uploads the optimised bytes directly to S3.
browser your backend S3
│ compressImage(file) │ │
│ POST /api/presign {name,type,size} │
│ ───────────────────────────────▶ │ validate, choose key, │
│ │ getSignedUrl(PutObject{Key, │
│ ◀─────────────── {url, key} ──── │ ContentType}, 300 s) │
│ PUT url (Content-Type = signed type, body = result.blob) │
│ ───────────────────────────────────────────────────────────────────▶│Frontend
import { compressAndUpload } from "optiq-image/upload";
const out = await compressAndUpload(file, {
compression: { mode: "balanced", preserveMetadata: false },
requestPresignedUpload: async ({ fileName, contentType, size, signal }) => {
const r = await fetch("/api/presign", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fileName, contentType, size }),
signal,
});
if (!r.ok) throw new Error(`presign failed: ${r.status}`);
return r.json(); // { url, key } or { url, method: "POST", fields, key }
},
signal: controller.signal,
onCompressProgress: (e) => setProgress(e.progress),
onUploadProgress: (p) => setUpload(p.loaded / p.total),
});
// out.uploaded: "optimized" | "original"; out.compressionError is set if compression failed and the original was sent.fileName and contentType passed to your presign endpoint already match the encoded output (photo.HEIC.jpg → photo.HEIC.webp, image/webp), so the stored object's key extension and Content-Type are consistent. If compression fails or does not reduce the size, the original file is uploaded with its own name and detected type. Upload failures throw UploadError; the caller still holds the original File for a retry.
Backend example (Node, AWS SDK v3)
This is a reference sketch; it is not exercised by this repository's tests (no AWS account is used here). The demo (pnpm demo) runs an in-memory mock of the same contract instead.
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "node:crypto";
const s3 = new S3Client({ region: process.env.AWS_REGION });
const ALLOWED = new Set(["image/jpeg", "image/png", "image/webp", "image/avif", "image/gif"]);
app.post("/api/presign", requireUser, async (req, res) => {
const { fileName, contentType, size } = req.body;
if (!ALLOWED.has(contentType) || !(size > 0 && size <= 25 * 1024 * 1024)) return res.status(400).end();
const ext = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "image/avif": "avif", "image/gif": "gif" }[contentType];
const key = `uploads/${req.user.id}/${randomUUID()}.${ext}`; // never trust the client's name for the key
const url = await getSignedUrl(s3, new PutObjectCommand({ Bucket: process.env.BUCKET, Key: key, ContentType: contentType, ContentLength: size }), { expiresIn: 300 });
res.json({ url, key });
});Notes:
- Sign
ContentType(and optionallyContentLength) so the upload must match what was approved. - The bucket needs a CORS rule allowing
PUTfrom your origin with theContent-Typeheader. - Validate the object server-side after upload if you rely on its type (clients can lie); Optiq's sniffing protects your users from mislabelled files, not your backend from malicious clients.
- For presigned POST policies return
{ url, method: "POST", fields, key }; the helper sendsfields, thenContent-Type, then the file last, as S3 requires.