uploader.jpの終了に合わせてCloudflare Workers・D1・R2でアップローダーを作った
レンタルアップローダーのuploader.jp(getuploader.com)が2026年11月25日に終了します。
終了すると置かれているファイルも掲示板やWikiに貼られたリンクも使えなくなります。
そこでuploader.jpと同じように使える個人運営のアップローダーogauploaderを作り
2026年10月に公開しました。
アップローダーごとの一覧ページ、訪問者のアップロード、パスワードでの保護、直リンク
といった機能をそろえています。
この記事ではCloudflare Workers・D1・R2 でどう組み立てたかと
作る中で引っかかった点を紹介します。
!
ogauploaderはuploader.jpとは無関係の個人運営サービスです。
全体の構成
役割
使ったもの
画面・API・ダウンロードの配信
Cloudflare Workers(Hono+hono/jsx)
データベース
D1
ファイルの保存
R2(非公開のバケット)
大きいファイルのハッシュ計算
Queues
期限切れのデータの片付け
Cron Triggers
メール
Email Service(失敗したらResendで再送)
ボット対策
Turnstile
有料プラン
Stripe(Checkoutとカスタマーポータル)
Workerは1つだけで
画面とAPIのogauploader.comとダウンロード配信のogaup.netの両方を受けています。
1つのWorkerで2つのドメインを受ける
利用者がアップロードしたファイルはサービス本体とは別のドメインから配信しています。
理由は2つあります。
本体の Cookie をファイルの配信に渡さないため
万が一ファイルがブラウザでページとして開かれても
本体のログイン状態には触れられません。
配信用のドメインが危険なサイトとして登録されても本体に影響させないため
アップローダーなので悪意のあるファイルが置かれる可能性は避けられません。
その場合でもサービス全体が開けなくなる事態は避けたいと考えました。
ドメインは2つでも Worker は1つにして、ホスト名で振り分けています。
src/index.ts
export default {
fetch(req, env, ctx) {
const url = new URL(req.url);
const app = url.host === new URL(env.FILES_ORIGIN).host ? files : web;
return app.fetch(req, env, ctx);
},
} satisfies ExportedHandler<Env>;
配信するときはファイルの種類にかかわらずダウンロードとして返し
ブラウザで中身が実行されないようにしています。
headers.set("Content-Disposition", contentDisposition(file.filename)); // 常に attachment
headers.set("X-Content-Type-Options", "nosniff");
headers.set("Content-Security-Policy", "sandbox; default-src 'none'");
headers.set("Cross-Origin-Resource-Policy", "cross-origin"); // 他サイトへの画像の埋め込みは許す
R2のバケットは公開せずダウンロードはすべてWorkerを通しています。
Workerを通すのでパスワードの確認、削除されたファイルを410で返すこと
ダウンロード数を数えることを1か所で扱えます。
Rangeにも対応しているので、途中から再開するダウンロードにも使えます。
アップロードはブラウザからR2へ直接送る
Workerが受け取れるリクエストの大きさには上限があります。
CPU時間やメモリーも大きいファイルの中継には向きません。
そこでファイルの本体はR2のS3互換APIへブラウザから直接送っています。
Workerは送信先の署名付きURLを発行するだけです。
申告どおりのファイルしか受け付けない
署名にはContent-Type、Content-Length、x-amz-checksum-sha256を含めています。
申告と違う大きさや中身のファイルを送るとR2が拒否します。
署名にはaws4fetchを使いました。
aws4fetchは既定ではContent-Typeなどのヘッダーを署名に含めません。
allHeaders: trueを付ける必要がありここで少し引っかかりました。
src/lib/presign.ts
const signed = await client.sign(url.toString(), {
method: "PUT",
headers: {
"Content-Type": contentType,
"Content-Length": String(size),
"x-amz-checksum-sha256": hexToBase64(sha256Hex),
},
aws: { signQuery: true, allHeaders: true },
});
ブラウザから直接PUTするのでR2のバケットにCORSの設定が必要です。
マルチパートで各部品のETagを読むためETagも公開しておきます。
r2-cors.json
{
"rules": [
{
"allowed": {
"origins": ["ogauploader.com"],
"methods": ["PUT"],
"headers": ["content-type", "x-amz-checksum-sha256"]
},
"exposeHeaders": ["ETag"],
"maxAgeSeconds": 3600
}
]
}
容量は開始のときに予約する
容量の上限を守るためアップロードを始める時点で容量を予約しています。
条件付きのUPDATE1文で済ませているので同時にアップロードされても上限を超えません。
src/db/quota.ts
// 上限を超えるなら更新されない(changes が 0 になる)
const result = await db
.prepare("UPDATE users SET used_bytes = used_bytes + ? WHERE id = ? AND used_bytes + ? <= ?")
.bind(bytes, userId, bytes, quota)
.run();
return result.meta.changes === 1;
完了の連絡が来たらR2のHEADで届いた実物のサイズとSHA-256を確かめてから公開します。
R2は保存したチェックサムをchecksums.sha256で返します。
完了しないまま放置されたアップロードはCron Triggersで消して予約した容量を戻します。
const object = await env.BUCKET.head(file.r2_key);
const stored = object.checksums.sha256; // R2 が保存した SHA-256(ArrayBuffer)
if (object.size !== file.size || toHex(stored) !== file.sha256) {
// 申告と違うので取り消す
}
大きいファイルはマルチパートで送る
16MBを超えるファイルは8MBずつの部品に分けて送ります。
R2のマルチパートは最後以外の部品を同じ大きさにする必要があり部品の最小は5MiBです。
マルチパートの開始と組み立てにはS3 APIではなくWorkerのR2バインディング(createMultipartUpload / resumeMultipartUpload().complete())を使っています。
ブラウザがS3 APIで使うのは部品を送る部分だけです。
部品のURLにもContent-Lengthを署名しています。
SHA-256は公開した後でWorkerが計算する
大きいファイルのSHA-256をブラウザで計算すると
ファイル全体をメモリーに読み込むことになります。
そこでマルチパートのファイルはブラウザでは計算せず
公開した後でQueues経由でWorkerが計算します。
Workersにはストリームを流し込むだけでハッシュを計算できる
crypto.DigestStreamがあります。
R2から読みながら計算できるのでメモリーをほとんど使いません。
src/jobs/hash.ts
const object = await env.BUCKET.get(file.r2_key);
const digest = new crypto.DigestStream("SHA-256");
await object.body.pipeTo(digest);
const sha256 = toHex(await digest.digest);
実測では時間の大半はR2からの読み出し待ちでした。
CPU時間は100MBあたり1秒もかかりません。
同じ中身のファイルは1つだけ保存する
アップローダーには同じファイルが何度も上げられることがあります。
そこで中身のSHA-256が同じファイルはR2には1つだけ置いて共有しています。
完了したときにblobsテーブルへ登録します。
同じ中身がすでにあれば参照の数を1つ増やし今回届いた分のR2オブジェクトを消します。
INSERT INTO blobs (sha256, size, r2_key, ref_count, created_at)
VALUES (?, ?, ?, 1, ?)
ON CONFLICT(sha256) DO UPDATE SET ref_count = ref_count + 1
配信するときはCOALESCE(blobs.r2_key, files.r2_key)で実体の場所を決めます。
ファイルを消したら参照の数を減らし、0になったらR2のオブジェクトを消します。
重複した分は応答を返した後(waitUntil)で消します。
応答までの時間から同じファイルがすでにあるかを推測されないようにするためです。
「ハッシュが同じならアップロードを省略する」はしない
クラウドストレージには同じ中身がすでにあれば送信を省略して
一瞬で終わらせる仕組みを持つものもあります。
ogauploaderではこれをしていません。
ハッシュを知っているだけで他人のファイルを自分のアップローダーに入れて
手に入れられてしまうからです。
必ず中身を送ってもらいR2がSHA-256を照合したうえで保存の段階でだけまとめています。
パスワード付きファイルのダウンロード
配信用のドメインはCookieを持ちません。
パスワード付きのファイルは次の順でダウンロードします。
直リンクにアクセスすると本体のファイルのページへリダイレクトします。
そこでパスワードを確認します。
有効期限付きの署名トークンを付けて直リンクへ戻します。
トークンにはパスワードの指紋を含めています。
パスワードを変えると発行済みのトークンも使えなくなります。
引っかかったところ
wrangler devでホスト名の振り分けが効かなかった
routesがあるとwrangler devがリクエストのHostを書き換えます。
そこでローカル用の環境(env.dev)では"routes": []にしました。
ローカルではlocalhost:8787を本体、127.0.0.1:8787を配信用として振り分けています。
ローカルのR2にはS3 APIがない
署名付きURLへのPUTが試せないのでローカルのときだけWorker経由でR2に書き込む
開発用の API を用意しました。
PBKDF2の反復回数の上限
WorkersのWeb CryptoではPBKDF2の反復回数は10万回までです。
それより多くすると例外になります。
D1の1文あたりのパラメーター数の上限
まとめて削除するときにIN (?, ?, …)と並べると上限(100)に当たります。
番号はJSONの配列1つで渡しjson_eachで展開しました。
UPDATE files SET status = 'deleted'
WHERE uploader_id = ? AND file_no IN (SELECT value FROM json_each(?))
テスト
@cloudflare/vitest-pluginを使うとWorkersのランタイム(workerd)の上で
D1・R2・Queuesをローカルのままvitestでテストできます。
費用
固定でかかるのはWorkers Paidの月5ドルとドメイン代だけです。
D1やQueuesは今の規模ではWorkers Paidに含まれる量に十分収まっています。
(2026年10月時点の料金)
項目
料金
Workers Paid
月5ドル(1,000万リクエスト・3,000万CPUミリ秒を含む)
R2 の保存
1GBあたり月0.015ドル(10GBまでは無料)
R2 からの転送
無料
Email Service
月3,000通までWorkers Paidに含まれる
アップローダーで心配なのはダウンロードの転送量ですが、R2は外への転送が無料です。
保存量に比例する費用だけを見ればよく、1TB置いても月15ドルほどです。
これがR2を選んだいちばんの理由でした。
最新の料金はWorkersとR2の料金ページを確認してください。
おわりに
uploader.jpから移る方向けに移行方法と移行キャンペーンを用意しています。
(先着順で容量を1年間10GBに広げます)
ogauploaderは広告を載せず有料プランの収入で
運営費をまかなって長く続けることを目指しています。
ご意見は X(@ogauploader)か Bluesky(@ogauploader.com)までお寄せください。
Copy a remote video into R2 from a Cloudflare Worker without buffering it
If your Worker copies files it didn't create (AI generation results, imports from a URL, exports from another service), the obvious line, await res.arrayBuffer(), loads the whole file into an isolate whose 128 MB is shared by every request it is serving. For video that's the wrong default. You can stream the file straight from the source into R2 instead, and it takes three pieces: FixedLengthStream, pipeTo, and an S3 PUT signed as UNSIGNED-PAYLOAD.
The code below is trimmed from the copy step at Movoa, which moves each finished video from a provider's temporary URL into our own bucket.
TL;DR
- Workers memory is 128 MB per isolate, not per request, so buffered copies add up across concurrent requests.
- Stream when the source sends Content-Length: response.body.pipeTo(new FixedLengthStream(length).writable) and PUT the readable side.
- Sign the stream with aws4fetch; it uses UNSIGNED-PAYLOAD, which R2's S3 API accepts over HTTPS.
- Reject tiny bodies before writing, and fall back to buffering only when there's no length (or you're in Node).
Why buffering is the trap
The tempting version is three lines:
const res = await fetch(resultUrl);
const body = new Uint8Array(await res.arrayBuffer());
await bucket.put(key, body);
Cloudflare's limits page (read 2026-10-16) is specific about why this bites: each isolate "can consume up to 128 MB of memory", and "this limit is per-isolate, not per-invocation. A single isolate can handle many concurrent requests." When an isolate goes over, in-flight requests are allowed to finish and new requests go to a fresh isolate; under heavy load some incoming requests can be cancelled.
So the risk isn't one big file. It's two or three ordinary video files being copied by the same isolate at the same moment, each fully materialised in memory.
!Buffered copy holds the whole file in isolate memory; a streamed copy only holds the chunks in flight
The PUT needs a length, so stream only when you have one
A single S3 PUT to R2 wants a Content-Length. A raw ReadableStream doesn't carry one, which is where FixedLengthStream comes in. It's a Workers runtime TransformStream that you construct with the expected byte count: it tells the runtime the body's length up front, and it errors if the bytes that flow through don't match. That second property is a truncation check: if the source sends fewer than length bytes, the stream errors and the upload fails instead of leaving half a file in your bucket.
Here is the streaming branch, lightly trimmed from our code:
const response = await fetch(url, {
headers, // forward auth if the result URL needs it
redirect: 'follow',
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok || !response.body) return fail(`HTTP ${response.status}`);
const contentType = response.headers.get('content-type') || 'application/octet-stream';
const length = Number(response.headers.get('content-length') || NaN);
const FixedLength = (globalThis as any).FixedLengthStream; // only exists on workerd
if (FixedLength && Number.isFinite(length) && length > 0) {
if (length < minBytes) {
void response.body.cancel().catch(() => {});
return fail(`too small (${length} bytes)`);
}
const { readable, writable } = new FixedLength(length);
const piping = response.body.pipeTo(writable); // source → FixedLengthStream
const result = await putStream(env, { body: readable, length, key, contentType });
await piping.catch(() => {}); // surface nothing twice
return result;
}
Two details matter. The pipeTo promise is started but not awaited before the PUT, because the PUT is what drains the readable side; awaiting the pipe first would deadlock. And the feature check on globalThis.FixedLengthStream keeps the same module working under Node in local dev, where that class doesn't exist (more on that below).
Signing a body you haven't read
R2 speaks the S3 API, so the PUT is a signed request. SigV4 normally includes a hash of the payload, which is impossible for bytes you haven't received yet. aws4fetch handles this by signing a stream body as UNSIGNED-PAYLOAD, and R2 accepts that over HTTPS:
import { AwsClient } from 'aws4fetch';
// env: R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY (an R2 API token),
// R2_ENDPOINT = https://<account-id>.r2.cloudflarestorage.com, R2_BUCKET
async function putStream(env, { body, length, key, contentType }) {
const client = new AwsClient({
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
region: 'auto',
service: 's3',
});
const res = await client.fetch(`${env.R2_ENDPOINT}/${env.R2_BUCKET}/${key}`, {
method: 'PUT',
headers: {
'Content-Type': contentType,
'Content-Length': String(length),
},
body, // the FixedLengthStream readable
});
if (!res.ok) {
const detail = (await res.text().catch(() => '')).slice(0, 200);
return fail(`Upload failed: ${res.status} ${detail}`);
}
return { success: true, key, url: publicUrlFor(key) };
}
Checks before you write anything
- A 200 that is really an error. Some providers answer the result URL with a short JSON error and a 200 status when a file isn't ready or has expired. A minBytes threshold checked against Content-Length catches that before anything is written; cancel the body so the connection is released.
- Redirects and auth. Signed result URLs often redirect to object storage. Set redirect: 'follow' and pass through whatever headers the provider requires.
- Timeouts. AbortSignal.timeout(ms) on the fetch keeps a stalled source from holding the request open indefinitely. We give the whole download-and-upload 100 seconds.
!Decision flow: response not OK, then minBytes, then stream with FixedLengthStream when Content-Length is known on workerd, otherwise buffer
What about the R2 binding?
If your Worker has the bucket bound (env.BUCKET), put() accepts a ReadableStream too. The catch is the same: the stream needs a known length, which a fetch() response body with a Content-Length has, and the readable side of a FixedLengthStream has. So await env.BUCKET.put(key, response.body) streams when the source sends a length; for anything else, wrap it in FixedLengthStream first.
We use the S3 API instead because the same storage module also runs under Node (local dev and scripts), where there is no binding, and one signed-request path serves both.
When you can't stream
Two cases still take the buffered path:
1. The source sends no Content-Length (chunked transfer). Without a length there's nothing to give FixedLengthStream, and a single PUT can't be sent. Buffering is the simple answer; for large files, a multipart upload with fixed-size parts is the scalable one.
2. Local development under Node (next dev, next start), where FixedLengthStream isn't defined. The feature check routes these to arrayBuffer(), which is fine on a laptop.
// No length, or not on workerd: buffer, and know this is the memory path.
const body = new Uint8Array(await response.arrayBuffer());
if (body.length < minBytes) return fail(`too small (${body.length} bytes)`);
return uploadFile({ body, key, contentType });
Checklist
- Check response.ok and Content-Length before touching storage.
- On workerd with a known length: pipeTo into new FixedLengthStream(length), PUT the readable side, then settle the pipe promise.
- Sign with aws4fetch (UNSIGNED-PAYLOAD); send Content-Length explicitly.
- Reject bodies under a sane minimum; follow redirects; set a timeout.
- Buffer only as the fallback, and treat it as the path that costs memory.
On the streaming branch the file never sits in your isolate's memory, and a short read fails loudly instead of saving half a video. The buffered branch is the exception you keep for chunked sources and local dev.
The videos this copies come from the Movoa video generator, which turns one sentence or a photo into a clip with sound; the first one is free and needs no account.
1 comment
Mentions
uploader.jpの終了に合わせてCloudflare Workers・D1・R2でアップローダーを作った レンタルアップローダーのuploader.jp(getuploader.com)が2026年11月25日に終了します。 終了すると置かれているファイルも掲示板やWikiに貼られたリンクも使えなくなります。 そこでuploader.jpと同じように使える個人運営のアップローダーogauploaderを作り 2026年10月に公開しました。 アップローダーごとの一覧ページ、訪問者のアップロード、パスワードでの保護、直リンク といった機能をそろえています。 この記事ではCloudflare Workers・D1・R2 でどう組み立てたかと 作る中で引っかかった点を紹介します。 ! ogauploaderはuploader.jpとは無関係の個人運営サービスです。 全体の構成 役割 使ったもの 画面・API・ダウンロードの配信 Cloudflare Workers(Hono+hono/jsx) データベース D1 ファイルの保存 R2(非公開のバケット) 大きいファイルのハッシュ計算 Queues 期限切れのデータの片付け Cron Triggers メール Email Service(失敗したらResendで再送) ボット対策 Turnstile 有料プラン Stripe(Checkoutとカスタマーポータル) Workerは1つだけで 画面とAPIのogauploader.comとダウンロード配信のogaup.netの両方を受けています。 1つのWorkerで2つのドメインを受ける 利用者がアップロードしたファイルはサービス本体とは別のドメインから配信しています。 理由は2つあります。 本体の Cookie をファイルの配信に渡さないため 万が一ファイルがブラウザでページとして開かれても 本体のログイン状態には触れられません。 配信用のドメインが危険なサイトとして登録されても本体に影響させないため アップローダーなので悪意のあるファイルが置かれる可能性は避けられません。 その場合でもサービス全体が開けなくなる事態は避けたいと考えました。 ドメインは2つでも Worker は1つにして、ホスト名で振り分けています。 src/index.ts export default { fetch(req, env, ctx) { const url = new URL(req.url); const app = url.host === new URL(env.FILES_ORIGIN).host ? files : web; return app.fetch(req, env, ctx); }, } satisfies ExportedHandler<Env>; 配信するときはファイルの種類にかかわらずダウンロードとして返し ブラウザで中身が実行されないようにしています。 headers.set("Content-Disposition", contentDisposition(file.filename)); // 常に attachment headers.set("X-Content-Type-Options", "nosniff"); headers.set("Content-Security-Policy", "sandbox; default-src 'none'"); headers.set("Cross-Origin-Resource-Policy", "cross-origin"); // 他サイトへの画像の埋め込みは許す R2のバケットは公開せずダウンロードはすべてWorkerを通しています。 Workerを通すのでパスワードの確認、削除されたファイルを410で返すこと ダウンロード数を数えることを1か所で扱えます。 Rangeにも対応しているので、途中から再開するダウンロードにも使えます。 アップロードはブラウザからR2へ直接送る Workerが受け取れるリクエストの大きさには上限があります。 CPU時間やメモリーも大きいファイルの中継には向きません。 そこでファイルの本体はR2のS3互換APIへブラウザから直接送っています。 Workerは送信先の署名付きURLを発行するだけです。 申告どおりのファイルしか受け付けない 署名にはContent-Type、Content-Length、x-amz-checksum-sha256を含めています。 申告と違う大きさや中身のファイルを送るとR2が拒否します。 署名にはaws4fetchを使いました。 aws4fetchは既定ではContent-Typeなどのヘッダーを署名に含めません。 allHeaders: trueを付ける必要がありここで少し引っかかりました。 src/lib/presign.ts const signed = await client.sign(url.toString(), { method: "PUT", headers: { "Content-Type": contentType, "Content-Length": String(size), "x-amz-checksum-sha256": hexToBase64(sha256Hex), }, aws: { signQuery: true, allHeaders: true }, }); ブラウザから直接PUTするのでR2のバケットにCORSの設定が必要です。 マルチパートで各部品のETagを読むためETagも公開しておきます。 r2-cors.json { "rules": [ { "allowed": { "origins": ["ogauploader.com"], "methods": ["PUT"], "headers": ["content-type", "x-amz-checksum-sha256"] }, "exposeHeaders": ["ETag"], "maxAgeSeconds": 3600 } ] } 容量は開始のときに予約する 容量の上限を守るためアップロードを始める時点で容量を予約しています。 条件付きのUPDATE1文で済ませているので同時にアップロードされても上限を超えません。 src/db/quota.ts // 上限を超えるなら更新されない(changes が 0 になる) const result = await db .prepare("UPDATE users SET used_bytes = used_bytes + ? WHERE id = ? AND used_bytes + ? <= ?") .bind(bytes, userId, bytes, quota) .run(); return result.meta.changes === 1; 完了の連絡が来たらR2のHEADで届いた実物のサイズとSHA-256を確かめてから公開します。 R2は保存したチェックサムをchecksums.sha256で返します。 完了しないまま放置されたアップロードはCron Triggersで消して予約した容量を戻します。 const object = await env.BUCKET.head(file.r2_key); const stored = object.checksums.sha256; // R2 が保存した SHA-256(ArrayBuffer) if (object.size !== file.size || toHex(stored) !== file.sha256) { // 申告と違うので取り消す } 大きいファイルはマルチパートで送る 16MBを超えるファイルは8MBずつの部品に分けて送ります。 R2のマルチパートは最後以外の部品を同じ大きさにする必要があり部品の最小は5MiBです。 マルチパートの開始と組み立てにはS3 APIではなくWorkerのR2バインディング(createMultipartUpload / resumeMultipartUpload().complete())を使っています。 ブラウザがS3 APIで使うのは部品を送る部分だけです。 部品のURLにもContent-Lengthを署名しています。 SHA-256は公開した後でWorkerが計算する 大きいファイルのSHA-256をブラウザで計算すると ファイル全体をメモリーに読み込むことになります。 そこでマルチパートのファイルはブラウザでは計算せず 公開した後でQueues経由でWorkerが計算します。 Workersにはストリームを流し込むだけでハッシュを計算できる crypto.DigestStreamがあります。 R2から読みながら計算できるのでメモリーをほとんど使いません。 src/jobs/hash.ts const object = await env.BUCKET.get(file.r2_key); const digest = new crypto.DigestStream("SHA-256"); await object.body.pipeTo(digest); const sha256 = toHex(await digest.digest); 実測では時間の大半はR2からの読み出し待ちでした。 CPU時間は100MBあたり1秒もかかりません。 同じ中身のファイルは1つだけ保存する アップローダーには同じファイルが何度も上げられることがあります。 そこで中身のSHA-256が同じファイルはR2には1つだけ置いて共有しています。 完了したときにblobsテーブルへ登録します。 同じ中身がすでにあれば参照の数を1つ増やし今回届いた分のR2オブジェクトを消します。 INSERT INTO blobs (sha256, size, r2_key, ref_count, created_at) VALUES (?, ?, ?, 1, ?) ON CONFLICT(sha256) DO UPDATE SET ref_count = ref_count + 1 配信するときはCOALESCE(blobs.r2_key, files.r2_key)で実体の場所を決めます。 ファイルを消したら参照の数を減らし、0になったらR2のオブジェクトを消します。 重複した分は応答を返した後(waitUntil)で消します。 応答までの時間から同じファイルがすでにあるかを推測されないようにするためです。 「ハッシュが同じならアップロードを省略する」はしない クラウドストレージには同じ中身がすでにあれば送信を省略して 一瞬で終わらせる仕組みを持つものもあります。 ogauploaderではこれをしていません。 ハッシュを知っているだけで他人のファイルを自分のアップローダーに入れて 手に入れられてしまうからです。 必ず中身を送ってもらいR2がSHA-256を照合したうえで保存の段階でだけまとめています。 パスワード付きファイルのダウンロード 配信用のドメインはCookieを持ちません。 パスワード付きのファイルは次の順でダウンロードします。 直リンクにアクセスすると本体のファイルのページへリダイレクトします。 そこでパスワードを確認します。 有効期限付きの署名トークンを付けて直リンクへ戻します。 トークンにはパスワードの指紋を含めています。 パスワードを変えると発行済みのトークンも使えなくなります。 引っかかったところ wrangler devでホスト名の振り分けが効かなかった routesがあるとwrangler devがリクエストのHostを書き換えます。 そこでローカル用の環境(env.dev)では"routes": []にしました。 ローカルではlocalhost:8787を本体、127.0.0.1:8787を配信用として振り分けています。 ローカルのR2にはS3 APIがない 署名付きURLへのPUTが試せないのでローカルのときだけWorker経由でR2に書き込む 開発用の API を用意しました。 PBKDF2の反復回数の上限 WorkersのWeb CryptoではPBKDF2の反復回数は10万回までです。 それより多くすると例外になります。 D1の1文あたりのパラメーター数の上限 まとめて削除するときにIN (?, ?, …)と並べると上限(100)に当たります。 番号はJSONの配列1つで渡しjson_eachで展開しました。 UPDATE files SET status = 'deleted' WHERE uploader_id = ? AND file_no IN (SELECT value FROM json_each(?)) テスト @cloudflare/vitest-pluginを使うとWorkersのランタイム(workerd)の上で D1・R2・Queuesをローカルのままvitestでテストできます。 費用 固定でかかるのはWorkers Paidの月5ドルとドメイン代だけです。 D1やQueuesは今の規模ではWorkers Paidに含まれる量に十分収まっています。 (2026年10月時点の料金) 項目 料金 Workers Paid 月5ドル(1,000万リクエスト・3,000万CPUミリ秒を含む) R2 の保存 1GBあたり月0.015ドル(10GBまでは無料) R2 からの転送 無料 Email Service 月3,000通までWorkers Paidに含まれる アップローダーで心配なのはダウンロードの転送量ですが、R2は外への転送が無料です。 保存量に比例する費用だけを見ればよく、1TB置いても月15ドルほどです。 これがR2を選んだいちばんの理由でした。 最新の料金はWorkersとR2の料金ページを確認してください。 おわりに uploader.jpから移る方向けに移行方法と移行キャンペーンを用意しています。 (先着順で容量を1年間10GBに広げます) ogauploaderは広告を載せず有料プランの収入で 運営費をまかなって長く続けることを目指しています。 ご意見は X(@ogauploader)か Bluesky(@ogauploader.com)までお寄せください。
Copy a remote video into R2 from a Cloudflare Worker without buffering it If your Worker copies files it didn't create (AI generation results, imports from a URL, exports from another service), the obvious line, await res.arrayBuffer(), loads the whole file into an isolate whose 128 MB is shared by every request it is serving. For video that's the wrong default. You can stream the file straight from the source into R2 instead, and it takes three pieces: FixedLengthStream, pipeTo, and an S3 PUT signed as UNSIGNED-PAYLOAD. The code below is trimmed from the copy step at Movoa, which moves each finished video from a provider's temporary URL into our own bucket. TL;DR - Workers memory is 128 MB per isolate, not per request, so buffered copies add up across concurrent requests. - Stream when the source sends Content-Length: response.body.pipeTo(new FixedLengthStream(length).writable) and PUT the readable side. - Sign the stream with aws4fetch; it uses UNSIGNED-PAYLOAD, which R2's S3 API accepts over HTTPS. - Reject tiny bodies before writing, and fall back to buffering only when there's no length (or you're in Node). Why buffering is the trap The tempting version is three lines: const res = await fetch(resultUrl); const body = new Uint8Array(await res.arrayBuffer()); await bucket.put(key, body); Cloudflare's limits page (read 2026-10-16) is specific about why this bites: each isolate "can consume up to 128 MB of memory", and "this limit is per-isolate, not per-invocation. A single isolate can handle many concurrent requests." When an isolate goes over, in-flight requests are allowed to finish and new requests go to a fresh isolate; under heavy load some incoming requests can be cancelled. So the risk isn't one big file. It's two or three ordinary video files being copied by the same isolate at the same moment, each fully materialised in memory. !Buffered copy holds the whole file in isolate memory; a streamed copy only holds the chunks in flight The PUT needs a length, so stream only when you have one A single S3 PUT to R2 wants a Content-Length. A raw ReadableStream doesn't carry one, which is where FixedLengthStream comes in. It's a Workers runtime TransformStream that you construct with the expected byte count: it tells the runtime the body's length up front, and it errors if the bytes that flow through don't match. That second property is a truncation check: if the source sends fewer than length bytes, the stream errors and the upload fails instead of leaving half a file in your bucket. Here is the streaming branch, lightly trimmed from our code: const response = await fetch(url, { headers, // forward auth if the result URL needs it redirect: 'follow', signal: AbortSignal.timeout(timeoutMs), }); if (!response.ok || !response.body) return fail(`HTTP ${response.status}`); const contentType = response.headers.get('content-type') || 'application/octet-stream'; const length = Number(response.headers.get('content-length') || NaN); const FixedLength = (globalThis as any).FixedLengthStream; // only exists on workerd if (FixedLength && Number.isFinite(length) && length > 0) { if (length < minBytes) { void response.body.cancel().catch(() => {}); return fail(`too small (${length} bytes)`); } const { readable, writable } = new FixedLength(length); const piping = response.body.pipeTo(writable); // source → FixedLengthStream const result = await putStream(env, { body: readable, length, key, contentType }); await piping.catch(() => {}); // surface nothing twice return result; } Two details matter. The pipeTo promise is started but not awaited before the PUT, because the PUT is what drains the readable side; awaiting the pipe first would deadlock. And the feature check on globalThis.FixedLengthStream keeps the same module working under Node in local dev, where that class doesn't exist (more on that below). Signing a body you haven't read R2 speaks the S3 API, so the PUT is a signed request. SigV4 normally includes a hash of the payload, which is impossible for bytes you haven't received yet. aws4fetch handles this by signing a stream body as UNSIGNED-PAYLOAD, and R2 accepts that over HTTPS: import { AwsClient } from 'aws4fetch'; // env: R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY (an R2 API token), // R2_ENDPOINT = https://<account-id>.r2.cloudflarestorage.com, R2_BUCKET async function putStream(env, { body, length, key, contentType }) { const client = new AwsClient({ accessKeyId: env.R2_ACCESS_KEY_ID, secretAccessKey: env.R2_SECRET_ACCESS_KEY, region: 'auto', service: 's3', }); const res = await client.fetch(`${env.R2_ENDPOINT}/${env.R2_BUCKET}/${key}`, { method: 'PUT', headers: { 'Content-Type': contentType, 'Content-Length': String(length), }, body, // the FixedLengthStream readable }); if (!res.ok) { const detail = (await res.text().catch(() => '')).slice(0, 200); return fail(`Upload failed: ${res.status} ${detail}`); } return { success: true, key, url: publicUrlFor(key) }; } Checks before you write anything - A 200 that is really an error. Some providers answer the result URL with a short JSON error and a 200 status when a file isn't ready or has expired. A minBytes threshold checked against Content-Length catches that before anything is written; cancel the body so the connection is released. - Redirects and auth. Signed result URLs often redirect to object storage. Set redirect: 'follow' and pass through whatever headers the provider requires. - Timeouts. AbortSignal.timeout(ms) on the fetch keeps a stalled source from holding the request open indefinitely. We give the whole download-and-upload 100 seconds. !Decision flow: response not OK, then minBytes, then stream with FixedLengthStream when Content-Length is known on workerd, otherwise buffer What about the R2 binding? If your Worker has the bucket bound (env.BUCKET), put() accepts a ReadableStream too. The catch is the same: the stream needs a known length, which a fetch() response body with a Content-Length has, and the readable side of a FixedLengthStream has. So await env.BUCKET.put(key, response.body) streams when the source sends a length; for anything else, wrap it in FixedLengthStream first. We use the S3 API instead because the same storage module also runs under Node (local dev and scripts), where there is no binding, and one signed-request path serves both. When you can't stream Two cases still take the buffered path: 1. The source sends no Content-Length (chunked transfer). Without a length there's nothing to give FixedLengthStream, and a single PUT can't be sent. Buffering is the simple answer; for large files, a multipart upload with fixed-size parts is the scalable one. 2. Local development under Node (next dev, next start), where FixedLengthStream isn't defined. The feature check routes these to arrayBuffer(), which is fine on a laptop. // No length, or not on workerd: buffer, and know this is the memory path. const body = new Uint8Array(await response.arrayBuffer()); if (body.length < minBytes) return fail(`too small (${body.length} bytes)`); return uploadFile({ body, key, contentType }); Checklist - Check response.ok and Content-Length before touching storage. - On workerd with a known length: pipeTo into new FixedLengthStream(length), PUT the readable side, then settle the pipe promise. - Sign with aws4fetch (UNSIGNED-PAYLOAD); send Content-Length explicitly. - Reject bodies under a sane minimum; follow redirects; set a timeout. - Buffer only as the fallback, and treat it as the path that costs memory. On the streaming branch the file never sits in your isolate's memory, and a short read fails loudly instead of saving half a video. The buffered branch is the exception you keep for chunked sources and local dev. The videos this copies come from the Movoa video generator, which turns one sentence or a photo into a clip with sound; the first one is free and needs no account.