Repository navigation
Commit 8e31bb5
feat(api): direct-to-R2 asset upload (bump framework) (#137)
* feat(api/r2-presign): presigned R2 PUT URLs with SHA-256 enforcement
Adds packages/api/src/r2-presign.ts: a small module that generates
presigned PUT URLs for the direct-to-R2 asset upload flow described in
meta/design-docs/0068-direct-r2-asset-upload.md (Task 1).
The signed URL covers x-amz-checksum-sha256 in X-Amz-SignedHeaders, so
R2 verifies the uploaded body hashes to the declared digest at write
time. Tampering the header invalidates the signature, and a content
mismatch is rejected by R2 with HTTP 400 — no server-side rehash.
- Uses aws4fetch (signQuery + allHeaders) so Content-Type and the
checksum header are both included in the canonical request.
- Pure function: takes credentials/options as parameters, reads no env.
The endpoint that calls it (Task 2) loads R2_ACCESS_KEY_ID and
R2_SECRET_ACCESS_KEY from env and threads them through.
- Documents the two new secrets in env.ts and wrangler.jsonc.
- Tests cover URL shape, hex-to-base64 conversion, X-Amz-Expires TTL,
determinism via a now override, and that x-amz-checksum-sha256 is in
X-Amz-SignedHeaders.
* docs(operations): document R2 presigning secrets
Add R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY to the API worker
secrets table and the fresh-environment provisioning checklist; they
are required by the new R2 presigning utility used for direct-to-R2
asset uploads in the deploy flow.
* feat(api/r2-presign): switch from SHA-256 to Content-MD5 for R2 upload integrity
Cloudflare R2's S3 compatibility layer documents Content-MD5 (RFC 1864)
as the supported PutObject checksum, while x-amz-checksum-sha256 is not
enforced and would silently no-op. The previous SHA-256 design would
have shipped a signed header R2 ignored, breaking the "R2 rejects
mismatched bytes at write time" guarantee on the wire.
Switching to Content-MD5 keeps the same architectural property with a
documented, supported wire contract:
- presignAssetPut takes md5 (32 hex) instead of sha256 (64 hex)
- header signed becomes Content-Md5, value is base64 of the raw 16-byte
digest with standard padding (not URL-safe)
- aws4fetch's allHeaders: true keeps the header genuinely covered by
the V4 signature, so R2's edge enforces it
MD5's weaker collision resistance is acceptable: the hash is purely an
upload-integrity primitive, content addressing stays on BLAKE3, and
project-scoped namespacing means any collision is exploitable only
against the attacker's own project. The no-overwrite-guard concern
from adversarial review is also closed, because R2 rejects any PUT
whose body MD5 doesn't match the declared header — replays and buggy
CLIs cannot clobber live keys with mismatched bytes.
Design doc 0068 updated end-to-end (assetManifest v2 schema, wire
contracts, Task 1-4 specs, Risks section). All 6 unit tests pass with
the empty-string MD5 fixture (d41d8cd98f00b204e9800998ecf8427e ->
1B2M2Y8AsgTpgAmY7PhCfg==).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(api/deploy): add direct-to-R2 upload endpoints + JSON-mode deploy
Task 2 of design doc 0068. Adds two endpoints to packages/api/src/routes/deploy.ts:
- POST /:id/deploy/upload-urls — issues batched presigned R2 PUT URLs
(Content-Md5 signed, 10-min TTL) so the CLI can upload asset bytes
directly to STATIC_ASSETS_R2 without going through the API Worker
request body. Auth/ownership mirrors /deploy/preflight.
- POST /:id/deploy now branches on Content-Type at handler entry. JSON
bodies route to handleManifestOnlyDeploy; multipart stays bit-for-bit
unchanged for old CLIs. The JSON-mode handler validates the v2 asset
manifest, head-checks each declared blake3 in R2 in bounded
concurrency, then threads through the same post-validate pipeline as
multipart (precheck-and-claim, dedupe short-circuit, status
transitions, NDJSON event stream, routing switch, retention cleanup,
placeholder reaper). For WfP deploys it hydrates assetsByHash by
fetching bytes back from R2 so the existing CF asset upload session
flow keeps working unchanged.
The post-parse pipeline is duplicated (carefully, with cross-reference
comments) rather than extracted into a helper. Extracting would have
required threading 15+ pieces of parse-loop-local state through a
shared function, with high risk of regressing the multipart path. The
duplication is mostly mechanical mirroring of the existing pipeline.
Adds R2_STATIC_ASSETS_BUCKET to wrangler vars (production + staging) so
the presigner can read the bucket NAME at runtime — STATIC_ASSETS_R2
binding only surfaces the R2Bucket object.
17 new integration tests in test/integration/deploy-direct-r2.test.ts
cover: presign-URL endpoint shape + validation + auth, JSON-mode static
deploy success path, missing-hash rejection, WfP deploy with
workerFiles, dedupe short-circuit, mid-pipeline cancellation,
v2-version enforcement, manifest validation, plus a multipart
backward-compat smoke test. All 506 existing API integration tests
still pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(api/deploy): three JSON-mode bugs from direct-to-R2 adversarial review
Three small but distinct bugs in the JSON-mode deploy handler introduced
in 4cc3524e (Task 2 of design-doc 0068). All three live in the same
handler so they ship as a single commit.
1. Carry-forward R2-head verification (static branch)
`handleManifestOnlyDeploy` previously R2-head'd every declared asset
hash and failed 400 if any were missing. But existing static deploys
may have small assets in KV (`STATIC_ASSETS`) — not R2 — and the
multipart path's preflight is happy to skip those without re-upload.
JSON mode now distinguishes carry-forward hashes (present in the
prior live deploy's `asset_hashes`) from new hashes (uploaded
direct-to-R2 this deploy). Only new hashes are R2-head'd. The
static-branch serving manifest also preserves the prior KV-vs-R2
placement for carry-forward entries; only newly uploaded hashes get
the `!{hash}` (R2) prefix. Mirrors the multipart static branch's
existing prev-prefix-preservation logic.
2. workerFiles rejection for spa/static manifests
The multipart path explicitly rejects `worker:*` parts when
`manifest.type` is `spa` or `static`. JSON mode silently accepted
them, then routed to the WfP branch — breaking the project type
invariants. Now reject 400 BEFORE the precheck-and-claim transaction
so a rejected request leaves no phantom placeholder row.
3. Canceling-placeholder reaper
After the placeholder row is inserted by the precheck-and-claim
transaction, several validation paths can early-return without going
through the catch handler (asset-verify failures, quota exceeded,
etc.). If `cancelDeployment` lands in that window, the row's status
flips to `canceling` and the existing finally-reaper — which only
handles `pending + manifest=null` — leaves the row stranded. Extend
both reapers (multipart + JSON) to also delete `canceling +
manifest=null` rows. Multipart had the same gap as a pre-existing
bug; fixed for consistency.
Tests added in `deploy-direct-r2.test.ts`:
- Carry-forward KV-stored asset survives a JSON-mode redeploy and the
new serving manifest preserves KV placement.
- workerFiles rejected 400 for `spa` and `static` types, and no
placeholder row remains.
- cancelDeployment racing the asset-verify early-return is reaped
cleanly (no row left in `canceling` or `pending`).
* fix(api/deploy): strip v2 manifest fields before Cloudflare WfP asset session
The framework CLI emits v2 assetManifest entries shaped as
{ blake3, md5, size, hash } where hash is a deprecated alias of blake3.
The platform accepts these via structural typing and propagates the
manifest mostly verbatim through the deploy pipeline. Cloudflare
documents the assets-upload-session body shape as { hash, size }
per entry only and may strictly validate.
Add normalizeForCfAssetSession in packages/api/src/assets.ts that
returns a fresh { hash, size } object per entry, and apply it at the
two CF-bound call sites in packages/api/src/routes/deploy.ts (the
multipart and JSON-mode WfP paths). The persisted manifest in the DB
asset_hashes column is unchanged so future preflight reasoning still
sees the full v2 shape.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(api/deploy): widen JSON body cap and lengthen presign TTL
Two regressions surfaced by the Codex adversarial review of the Task 4
direct-to-R2 deploy work:
1. MAX_JSON_DEPLOY_BODY_BYTES (32 MiB) was tighter than the per-module
raw cap (32 MiB) accounting for ~33% base64 expansion: a single
full-size worker module weighs ~43 MiB on the wire. Bump the cap to
64 MiB so a full-size module + manifest + assetManifest fits with
safety margin, still well under the 128 MiB Worker memory limit.
2. Presigned PUT TTL of 600s expired on the tail of slow deploys
(570 assets × concurrency 10 × residential uplink) before the
upload pool reached the asset, and R2's SignatureExpired 403 was
surfaced as terminal. Bump TTL to 1800s. Pathological multi-hour
deploys are deferred to the re-presign-on-expiry follow-up noted
inline.
Tests: assert X-Amz-Expires=1800 on issued upload URLs, and exercise
a JSON-mode deploy body comfortably above the legacy 32 MiB cap to
prove the regression is fixed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: bump framework to rebased HEAD + propagate git fields on JSON-mode
Picks up the framework branch's rebased HEAD f70264b (post origin/main
rebase). Also extends the JSON-mode `handleManifestOnlyDeploy` to read
`repositoryUrl`, `commitUrl`, `gitRef` from the body and pass them
through to `updateDeploymentManifest` so the new deploy metadata fields
introduced on platform main land for direct-to-R2 deploys too — not
just multipart.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: bump framework + drop dead body.vars reader on JSON-mode handler
Bumps framework submodule to 5c22706 — picks up:
- refactor(cli/package): buildVoidManifest pure, warning moved to callers
- refactor(cli/client): drop unused vars field on ManifestOnlyDeployPayload
Pairs the framework wire-shape cleanup with the platform side: removes
the defensive `body.vars` reader from `handleManifestOnlyDeploy`. No
released CLI ever sent top-level `body.vars` (env vars always travel
inside `manifest.vars`), so this is dead code, not a wire-compat
change. Replaced with a one-line comment documenting the contract.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(api): remove legacy multipart deploy upload path (bump framework)
The direct-to-R2 deploy path (design doc 0068) is now the only supported
flow. Drop the multipart streaming handler in `routes/deploy.ts` and its
supporting infrastructure:
- `routes/deploy.ts`: replace the multipart `app.post('/:id/deploy', ...)`
body with a direct dispatch to `handleManifestOnlyDeploy`. Drop the
multipart-only constants (`MAX_MANIFEST_BYTES`, `MAX_ASSET_MANIFEST_BYTES`,
`MAX_PREFLIGHT_BYTES`, `MAX_SMALL_TEXT_BYTES`, `STREAMED_ASSET_CLAIM_*`)
and helpers (`readPartBytes`, `readPartText`, `discardPartBody`,
`readPartAsBlob`, `streamedAssetClaimKey`).
- `assets.ts`: drop the `./multipart.ts` import. Inline the
`MultipartParseError` and `PartTooLargeError` shells locally — the
multipart streaming asset helpers (`verifyStreamedAssetPart`,
`streamStaticAssetToR2`, `readStaticAssetPart`, `uploadStaticAssetPart`,
`cleanupStreamedStaticAssets`) and `collectBufferedAsset` are now dead
code, but kept for now so this PR doesn't grow further; they'll be
swept in a follow-up since the JSON-mode handler no longer references
them.
- `multipart.ts` + `multipart.test.ts`: deleted outright.
- Test conversion: rewrite `api-deploy.test.ts` to use a JSON-mode
`buildDeployRequest` helper that pre-uploads asset bytes to
`STATIC_ASSETS_R2` and POSTs the manifest-only body. Drop the entire
"(streaming static)", "(streaming static direct-to-R2)", and "(edge
cases)" describe blocks — they tested multipart parser semantics that
no longer exist; manifest validation coverage stays in the
`manifest-rule-limits` unit test. Convert `api-sandbox.test.ts`,
`deploy-collision-warning.test.ts`, and `deploy-direct-r2.test.ts`
similarly. Drop the heartbeat test (the JSON-mode deploy is too fast
for the heartbeat-during-pipeline assertion to fire deterministically).
- `meta/design-docs/0068-direct-r2-asset-upload.md`: append a "Migration
complete" section noting the cleanup.
- Bump the framework submodule to the matching CLI commit.
Old CLI versions on npm will fail against the new platform with a clear
JSON 400 until users upgrade — accepted per the rollout plan.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: bump framework submodule to origin/main HEAD
Framework PR #192 (direct-to-R2 asset upload + legacy-multipart removal)
landed on framework main as 5d5f609. Bump the submodule pointer so the
platform PR references the merged commit instead of the now-deleted
feature branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(api/upload-urls): streaming size cap before JSON parse
The `POST /:id/deploy/upload-urls` handler called `c.req.json()` directly,
which buffers arbitrary body bytes before parsing. A malicious or buggy
client could pin Worker memory by sending a huge JSON blob. Replace with
the same TransformStream + size-cap pattern used by
`handleManifestOnlyDeploy`, capped at 4 MiB — generous for 5000 asset
declaration tuples (~750 KB) with a 4× safety margin. Asset bytes never
travel this endpoint; they go direct-to-R2 via the presigned PUTs it issues.
Codex P2 review finding.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(api/deploy): stream JSON body via TransformStream to avoid OOM
The JSON body in `handleManifestOnlyDeploy` was previously buffered as a
chunks Array, copied into a Uint8Array, decoded into a UTF-16 string, and
parsed — peaking at ~4× the body size in residency. For a legitimate
64 MiB body this means 240+ MiB of peak memory, dangerously close to the
Worker isolate's 128 MiB hard limit.
Replace with a counting TransformStream + native `Response.json()`. The
TransformStream aborts the stream the moment the cap is exceeded; the
native parser consumes the stream incrementally with reused internal
buffers. The only retained allocation at the end is the parsed object.
Codex P1 review finding (high impact — every deploy goes through this).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(api/assets): remove dead multipart-streaming helpers
The streaming-static and worker-backed multipart deploy paths were retired
when JSON-mode direct-to-R2 became the only deploy entry point. The
helpers and their backing error classes were left behind as dead code:
- `MultipartParseError`, `PartTooLargeError` (stub classes)
- `streamStaticAssetToR2`, `verifyStreamedAssetPart`
- `readStaticAssetPart`, `uploadStaticAssetPart`
- `cleanupStreamedStaticAssets`, `collectBufferedAsset`
- `readAssetBody`, `updateBase64Hash` (private helpers)
- `PreparedStaticAsset` interface
- unused `createHasher` import
Drop them along with the now-orphan `cleanupStreamedStaticAssets` test
block. `applyDeclaredAssetManifest`, `normalizeForCfAssetSession`,
`buildComparableAssetManifestForDedupe`, `uploadStaticAssets`,
`uploadAssetBuckets`, `createAssetUploadSession`, and the worker-bundle
helpers all remain — those are still on the live path.
Verified production code carries no remaining references; integration
test setup files mention the names only inside historical comments.
Cursor LOW review finding (sweep follow-up).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(api/deploy): remove unreachable preflight fill-in loop
In `handleManifestOnlyDeploy` the post-`applyDeclaredAssetManifest`
preflight fill-in loop was unreachable. The two arguments are always the
same `incomingAssetManifest` object, so the helper produced an output
manifest containing every key from the input — and the loop's
`if (!(path in assetManifest))` predicate could never be true. The
`getLastLiveAssetManifest` fallback (`?? await ...`) was equally
unreachable because `incomingAssetManifest` is always non-null in this
handler. Replace `applyDeclaredAssetManifest(...)` with `{ ...incomingAssetManifest }`,
the only effect that the call ever had at this site.
Update the surrounding comment block to reflect that JSON-mode CLIs
already send the full assetManifest (preflight-skipped paths included).
Add a defensive contract test pinning that behaviour.
`getLastLiveAssetManifest` and `applyDeclaredAssetManifest` remain
imported / exported — they're still used elsewhere
(`buildComparableAssetManifestForDedupe`, rollbacks, persistence
recovery, `applyDeclaredAssetManifest` unit tests).
Cursor MEDIUM review finding (dead code).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(api/upload-urls): reject deleting projects with 410
The `POST /:id/deploy/upload-urls` handler issued writable presigned R2
URLs after only ownership/existence checks, even for projects already
marked `status='deleting'`. Such projects can never finalize a deploy —
`POST /:id/deploy` returns 410 — so any bytes uploaded against those
URLs become orphaned `assets/{projectId}/*` blobs that survive teardown
and add storage churn.
Mirror the guard `handleManifestOnlyDeploy` already enforces, returning
410 immediately after the ownership check and before any presigning.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* perf(api/deploy): single-pass base64 decode for workerFiles
The JSON-mode deploy handler decoded each base64-encoded user worker
module with `atob` + a hand-rolled per-byte loop, materializing four
full-size copies of the bytes (UTF-16 source string, latin1 `bin`
string, `Uint8Array`, and the eventual `Blob` part). Iterating via
`for…of Object.entries(...)` also captured the entire `workerFiles`
map for the full loop, holding every base64 string live until the
loop exited. For a near-cap 32 MiB module that pushed peak isolate
residency to ~214 MiB — past the 128 MiB worker cap — and terminated
the deploy before the pipeline ever ran. This was a regression from
the multipart path, whose blob-first handling never paid the
intermediate-string cost.
Two changes drop peak residency to ~118 MiB for one near-cap module
(under the cap with margin):
1. Prefer the (TC39 stage-4) `Uint8Array.fromBase64` helper — single
allocation, no intermediate string. Production CF Workers
(compatibility_date=2026-02-12) ships the helper natively; the
previous `atob` loop is kept as a fallback for runtimes that don't
expose it (Node-based integration tests).
2. Iterate over `Object.keys(...)` and `delete` each source string
from the parsed body after decode, so V8 can reclaim the ~86 MiB
UTF-16 backing string before the next module's decode begins.
Adds a regression test exercising a multi-entry `workerFiles` map
that round-trips every decoded module byte-for-byte through the
persisted R2 worker bundle — would surface either iteration skips
caused by `delete`-as-we-go or any byte-level corruption from the
`fromBase64` helper.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(api/teardown): sweep assets/{projectId}/* during project deletion
Direct-to-R2 deploy lets the CLI upload bytes to `assets/{projectId}/{blake3}`
before finalizing. Bytes uploaded via presigned PUTs that never reach a
finalized deploy (TOCTOU race against teardown, abandoned upload, etc.) live
durably in R2 — retention cleanup only walks hashes referenced by retained
manifests, so without a teardown sweep these would leak permanently.
Add `deleteProjectAssets(r2, projectId)` mirroring `deleteWorkerBundle`
(paginated list + bounded batch delete) and call it from
`cleanupBestEffortResources`. Failures are warnings, matching the
surrounding best-effort semantics.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(api/deploy): extend retention cleanup with R2 keyspace sweep
The retention loop only deleted hashes named in EVICTED deployments'
asset_hashes. Bytes uploaded via direct-to-R2 presigned PUTs that never
finalized into a live deploy are not named in any manifest, so they
outlived eviction indefinitely.
Add `sweepProjectAssetOrphans(r2, projectId, retainedHashes)` and call
it at the end of both retention paths (WfP + static). Also build the
retained-hashes union unconditionally — previously skipped when
`toEvict.length === 0`, which is precisely when the orphan sweep is
the only cleanup running. Best-effort try/catch so a sweep failure
never fails the deploy.
Plus a TODO in upload-urls noting the active-project case (abandoned
upload that never deploys) is still only opportunistically GC'd by
the next retention run; periodic per-project sweep is follow-up work.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(api/upload-urls): serialize presign with teardown via FOR SHARE lock
The unlocked status check in `POST /:id/deploy/upload-urls` was a TOCTOU
gap: a teardown calling `lockAndMarkDeleting` (FOR UPDATE) could land
between the unlocked read and the presigner, after which presigned R2
PUT URLs would let the CLI write durable orphan bytes against a
project being deleted.
Wrap the presign loop in a transaction that takes FOR SHARE on the
project row and re-reads the project status inside the lock. FOR SHARE
is compatible with concurrent upload-urls / deploy-precheck flows
(also reading project rows) but blocks teardown's FOR UPDATE until the
presign batch completes. A 5000-entry batch holds the lock for
~250 ms (presigning is pure HMAC, no I/O), which is comfortably
shorter than the surrounding teardown timeline.
The unlocked early-out is kept as a fast 410 path for the common case
where the project was already marked deleting before the request
arrived; the in-tx re-read is the authoritative check.
Adds a regression test that simulates the race by intercepting the
second `getProject` call (in-tx, post FOR SHARE) so it sees a
status='deleting' that committed between the two reads. To support
the test, `setup.ts` exposes a `setGetProjectOverride` hook similar
to the existing `setTestDb`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(api/assets): protect pending uploads in retention sweep with age window
Direct-to-R2 presigned PUTs land bytes in the asset keyspace before any
deploy manifest references them. The retention sweep walked every
`assets/${projectId}/*` key whose hash wasn't in the union of retained
manifests, which raced concurrent `upload-urls`-then-finalize flows on
the same project: a sweep firing between an upload-urls PUT and its
deploy's manifest persist would delete the bytes and break finalize with
"asset not found".
Add a 3600s pending-upload protection window (2× the 1800s presign TTL)
in `sweepProjectAssetOrphans`. R2 objects whose `uploaded` timestamp is
inside the window are preserved even when not in `retainedHashes`. The
tradeoff is documented in the function docstring and on the existing
`TODO(orphan-gc)` follow-up: orphans uploaded within the window only
clear on the next eligible deploy ≥ 1h later.
Also extends the test mock (`MemoryR2Bucket`) to expose
`uploaded` on `list()` responses and a `setUploadedAt` helper, so the
existing orphan-sweep regression can age its orphan past the window and
a new companion test covers the within-window preservation case.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(api/assets): extract shared paginated-list-and-delete helper
`deleteProjectAssets`, `sweepProjectAssetOrphans`, and `deleteWorkerBundle`
all repeated the same R2 walk-and-batch-delete loop. Pull it out into a
single `paginatedR2Delete(r2, prefix, { keepIf })` helper and let each
call site become a 1–4 line wrapper. The orphan sweep's hash filter and
pending-upload protection window are expressed as the `keepIf` predicate
so the helper stays generic.
Behaviour-preserving — every existing test (unit `assets.test.ts`,
integration `deploy-direct-r2.test.ts`, `api-delete.test.ts`,
`api-deploy.test.ts`) passes against the refactored implementations.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(api/r2-presign): split length vs character validation errors
`hexToBase64` reported \`got \${hex.length}\` regardless of which
constraint failed, so a 32-char string containing non-hex characters
produced the misleading \"got 32\" message — opaque during CLI
debugging. Split the check: length first, character-set second, with a
distinct message for each.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>1 parent 1d5c64c commit 8e31bb5
24 files changed
Lines changed: 3987 additions & 8399 deletions
File tree
- meta
- design-docs
- packages/api
- src
- provisioning
- routes
- test
- integration
- unit
- packages/void/src/cli/client.ts+106-10
- packages/void/src/cli/deploy.ts+301-84
- packages/void/src/cli/direct-r2-upload.ts+239
- packages/void/src/cli/package.ts+228-234
- packages/void/src/cli/types.ts+13
- packages/void/test/integration/direct-r2-deploy.test.ts+610
- packages/void/test/unit/asset-hashing.test.ts+137
- packages/void/test/unit/direct-r2-client.test.ts+251
- packages/void/test/unit/direct-r2-upload.test.ts+196
- packages/void/test/unit/migration-binding-consistency.test.ts+72-227
- packages/void/test/unit/package.test.ts+8-736
0 commit comments