Skip to content

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 ​

ts
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.

ts
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 optionally ContentLength) so the upload must match what was approved.
  • The bucket needs a CORS rule allowing PUT from your origin with the Content-Type header.
  • 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 sends fields, then Content-Type, then the file last, as S3 requires.