跳到正文
DEV Community· CokeBear·· 2 小時前AI 評分50

Clipboard Share 將單檔上載上限由 100 MB 提升至 5 GB,加入 Cloudflare R2 分片續傳與取消

From a 100 MB cap to 5 GB: Cloudflare R2 multipart upload with resume and cancel in the browser

AI 導讀

Clipboard Share 將單檔上載上限由 100 MB 提高至 5 GB,並為大型檔案改用 Cloudflare R2 分片上載,支援續傳和取消。100 MB 內的檔案仍使用單次 presigned PUT;較大檔案由瀏覽器直接傳送分片至 R2,伺服器負責簽署 URL 及追蹤狀態。續傳需在同一房間重新選取相同檔案;上載紀錄和每日用量存於伺服器記憶體,伺服器重啟會重置每日配額計數。

正文

I built Clipboard Share (https://www.clipboardapp.org), a no-login web clipboard for moving files between your phone and PC. For a while, files were capped at 100 MB because everything went up as a single presigned PUT to Cloudflare R2. That's simple, but a single dropped connection on a big file means starting over. This post covers how I replaced that for large files with multipart upload, resume, and cancel. Most of the code was written with Claude Code. The changes shipped as PRs #41 to #43 on 2026-10-01.

Every number below comes from the current master branch.

The shape of it

Files up to 100 MB still use one presigned PUT. Above that, the browser splits the file and talks to five small endpoints: create, sign, parts, complete, abort. Part bytes go straight from the browser to R2. My server only signs URLs and tracks state. (File data goes through R2, it isn't peer to peer.)

Limits: 5 GB per file, 10 GB per room, 20 GB per IP per day.

Part size, concurrency, retries

From frontend/src/services/multipartUploader.js:

export const MULTIPART_THRESHOLD = 100 * MB
export const DEFAULT_PART_SIZE = 64 * MB
export const MAX_SIGN_BATCH = 20
export const MAX_PART_RETRIES = 4
export const RETRY_BASE_DELAY_MS = 1000
/** signed URLs last 1h; re-sign after 50 min */
export const SIGN_TTL_MS = 50 * 60 * 1000

Parts are 64 MiB, so a 5 GB file is about 80 parts, far below the 10,000-part limit. Concurrency depends on the user agent: 4 parts in parallel on desktop, 2 on mobile. I picked those conservatively and haven't tuned them against real networks.

Each part gets up to 4 retries with 1s/2s/4s/8s backoff. Presigned URLs are valid for an hour on the server. The client stops trusting a URL after 50 minutes, and a 403/400 on a part drops its cached URL so the next attempt re-signs. Signing is batched, up to 20 parts per request.

Offline pause

A failure while navigator.onLine === false doesn't consume a retry. The worker just waits for the online event:

function waitForOnline() {
  if (typeof navigator === 'undefined' || navigator.onLine !== false) return Promise.resolve()
  return new Promise((resolve) => {
    const handler = () => {
      window.removeEventListener('online', handler)
      resolve()
    }
    window.addEventListener('online', handler)
  })
}

Resume with localStorage

When an upload starts, the session (uploadId, key, partSize, createdAt) is saved under a fingerprint key:

export const fingerprintOf = (roomId, file) =>
  `${STORAGE_PREFIX}${roomId}|${file.name}|${file.size}|${file.lastModified}`

If the user picks the same file again in the same room, the client asks the server (parts, which calls R2's ListParts) what already landed, and skips parts whose number and byte size match. If the upload no longer exists server-side, it clears the saved entry and starts fresh. Entries older than 24 hours are pruned on startup.

Server state

Upload records live in an in-memory Map (backend/src/utils/multipartStore.ts) for 25 hours. A record holds room, key, size, part size, part count, and the daily-quota key. It lets the server compute each part's expected length when signing, reject an uploadId that belongs to another room, and count in-flight reservations toward the room limit.

If the server restarts, the record is gone. When the client sends size along, the server rebuilds the record and marks it recovered: true. Recovered records don't reserve daily quota. On complete, the server also re-reads the object's real size and deletes it with a 422 SIZE_MISMATCH if it doesn't match what was declared.

The per-IP daily quota (backend/src/utils/dailyQuota.ts) keys on KST date : HMAC(IP) so raw IPs aren't stored. Aborting refunds everything except the bytes that actually uploaded:

if (record && !record.recovered) {
    releaseDailyQuota(record.ipKey, Math.max(0, record.size - uploaded));
}

Cleaning up unfinished uploads

Incomplete parts still take up storage. So when a room is cleared, in-flight multipart uploads are aborted first (backend/src/services/r2Service.ts):

// in-flight multipart uploads hold storage with unfinished parts, abort them first
const abortedCount = await this.abortRoomMultipartUploads(roomId);
deleteRecordsForRoom(roomId);

That method pages through ListMultipartUploads for the room prefix and aborts each upload. A failure on one logs a warning and the loop continues.

Cancel

Each file gets a handle with states queued, running, committed. Queued files cancel immediately. Running ones are cut with an AbortController. Once complete has been sent (committed), cancel is ignored so you never end up with a half-finished file. On cancel, the uploader clears the localStorage entry and fires abort without awaiting it, so the UI doesn't hang on the network call. A comment in the code says an R2 lifecycle rule would clean up if that abort fails. I haven't verified that rule from this repo, so that's something I still need to check in the R2 console.

Uploads that fail for network reasons deliberately keep their saved entry, so picking the same file again resumes.

What's still rough

Only things visible in the code:

  • Large files go one at a time; parallelism is only across parts within a file.
  • Resume needs the user to re-select the file, and the fingerprint is name + size + lastModified, so a renamed or edited file starts over.
  • Upload records and daily usage are in server memory. A restart resets the quota count.
  • The quota is per IP, so people on the same shared network share 20 GB a day.
  • Part size and concurrency are fixed, not adaptive.
  • If the ETag header can't be read (CORS misconfiguration), the part fails immediately instead of retrying.

If you try it, I'd like to hear what breaks. There's a feedback button at the bottom of the site ("건의하기"). https://www.clipboardapp.org

來源:DEV Community · dev.to