From 54599b82c4398830cb3315af19bdfdb0f53221e1 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 7 May 2026 19:00:58 +0800 Subject: [PATCH 1/8] feat(cli/deploy): add MD5 hashing alongside BLAKE3 + assetManifest v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3 of the direct-to-R2 asset upload series. The CLI now computes both BLAKE3 (existing CF-compatible storage key recipe) and MD5 (raw content digest) for every asset in a single disk read, and emits the v2 manifest entry shape `{ blake3, md5, size, hash }` where `hash` is a deprecated alias of `blake3` kept for one release. The deploy manifest now carries `assetManifestVersion: 2` from all three package builders (packageBuild, packageStaticBuild, packageFrameworkBuild). Wire transport is unchanged in this PR — the multipart endpoint still accepts the entries since the v1 `hash`/`size` fields remain present. Task 4 will flip the CLI to direct-to-R2 + JSON-mode finalization where the v2 fields become required. MD5 is the wire-level upload-integrity primitive that R2 enforces via `Content-MD5` (RFC 1864) on direct PUTs; BLAKE3 stays the canonical content-addressed storage key. See meta/design-docs/0068. Co-Authored-By: Claude Opus 4.7 --- packages/void/src/cli/package.ts | 68 ++++++++- packages/void/src/cli/types.ts | 13 ++ packages/void/test/unit/asset-hashing.test.ts | 137 ++++++++++++++++++ 3 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 packages/void/test/unit/asset-hashing.test.ts diff --git a/packages/void/src/cli/package.ts b/packages/void/src/cli/package.ts index 1210368e7..960bf1c18 100644 --- a/packages/void/src/cli/package.ts +++ b/packages/void/src/cli/package.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { hash as blake3hash } from 'blake3-jit'; import ignore from 'ignore'; @@ -151,6 +152,39 @@ export function computeAssetHash(content: Buffer, filePath: string): string { return Buffer.from(blake3hash(input)).toString('hex').slice(0, 32); } +/** + * Compute MD5 of raw content as a 32-char lowercase hex string. + * + * Used as the wire-level upload-integrity primitive on direct-to-R2 PUTs: + * R2's S3 compatibility layer enforces `Content-MD5` (RFC 1864) on PutObject, + * so the upload itself fails if uploaded bytes don't match the declared MD5. + * MD5 collision resistance is acceptable here because (a) this hash is purely + * an upload-integrity primitive (BLAKE3 remains the canonical content key), + * and (b) project-scoped storage (`assets/${projectId}/${blake3}`) means any + * collision attack is exploitable only against the attacker's own project. + */ +export function computeMd5(content: Buffer): string { + return createHash('md5').update(content).digest('hex'); +} + +/** + * Compute both BLAKE3 (CF-compatible asset hash) and MD5 (wire-level R2 + * upload-integrity checksum) from a single in-memory buffer. Returned in + * the v2 manifest entry shape for direct-to-R2 deploys. + * + * Single-pass: callers read the file once into `content`; this function + * then derives both digests from that buffer. The file is never read twice. + */ +export function computeAssetHashes( + content: Buffer, + filePath: string, +): { blake3: string; md5: string } { + return { + blake3: computeAssetHash(content, filePath), + md5: computeMd5(content), + }; +} + /** * Create a filter function that excludes .assetsignore, _headers, _redirects, * deploy-internal patterns, and any patterns listed in a .assetsignore file. @@ -202,14 +236,32 @@ function checkWorkerJsAsset(relativePath: string, assetsIgnoreFilePresent: boole } } +/** + * Asset manifest entry — v2 shape. Carries both hashes: + * - `blake3`: 32 hex chars; the canonical content-addressed storage key + * (`assets/${projectId}/${blake3}`). Compatible with Cloudflare's + * Workers Static Assets upload API. + * - `md5`: 32 hex chars; the wire-level upload-integrity primitive used + * by R2's `Content-MD5` (RFC 1864) enforcement on direct PUTs. + * + * The `hash` field is a deprecated v1 alias kept equal to `blake3` so + * existing CLI/test code that reads `entry.hash` keeps working for one + * release. Remove in a follow-up PR after consumers migrate to `blake3`. + */ export interface AssetManifestEntry { - hash: string; + blake3: string; + md5: string; size: number; + /** @deprecated v1 alias of `blake3`; remove in next release. */ + hash: string; } /** * Collect all files in a directory, compute hashes, and return both the * manifest (for preflight) and the raw file map (for selective packaging). + * + * Each file is read from disk exactly once. Both BLAKE3 (storage key) and + * MD5 (R2 upload-integrity) digests are derived from that single buffer. */ export async function collectAndHashAssets( dir: string, @@ -237,10 +289,17 @@ export async function collectAndHashAssets( continue; } checkWorkerJsAsset(relativePath, assetsIgnoreFilePresent); + // Single disk read per file; both hashes are derived from the same buffer. const content = readFileSync(filePath); + const { blake3, md5 } = computeAssetHashes(content, filePath); assetManifest[relativePath] = { - hash: computeAssetHash(content, filePath), + blake3, + md5, size: content.length, + // Deprecated v1 alias — kept equal to blake3 for one release so any + // consumer still reading `entry.hash` keeps working. Remove in a + // follow-up PR. + hash: blake3, }; assetFiles.set(relativePath, content); } @@ -354,7 +413,7 @@ export async function packageBuild( const formData = new FormData(); // Build manifest from inferred bindings - const manifest: DeployManifest = { version: 3, bindings: {} }; + const manifest: DeployManifest = { version: 3, assetManifestVersion: 2, bindings: {} }; if (bindings.needsAuth) { manifest.auth = true; } @@ -569,6 +628,7 @@ export async function packageStaticBuild( const manifest: DeployManifest = { version: 3, + assetManifestVersion: 2, bindings: {}, type: appType, assetConfig, @@ -654,7 +714,7 @@ export async function packageFrameworkBuild(opts: { const po = opts.packageOptions; // Build manifest - const manifest: DeployManifest = { version: 3, bindings: {} }; + const manifest: DeployManifest = { version: 3, assetManifestVersion: 2, bindings: {} }; const hasMigrations = opts.migrations && opts.migrations.length > 0; if (opts.bindings.needsD1 || opts.bindings.needsAuth || hasMigrations) { if (opts.dialect === 'postgresql') { diff --git a/packages/void/src/cli/types.ts b/packages/void/src/cli/types.ts index 6d0982cc0..0ad9a8f53 100644 --- a/packages/void/src/cli/types.ts +++ b/packages/void/src/cli/types.ts @@ -132,6 +132,19 @@ export interface SandboxManifestMeta { export interface DeployManifest { version: number; + /** + * Wire-format version of the per-asset entries in the sibling + * `assetManifest` payload (not this manifest object itself): + * - `1` (or omitted): legacy `{ hash, size }` shape. + * - `2`: `{ blake3, md5, size, hash? }` shape — required by the + * direct-to-R2 deploy path (R2 enforces `Content-MD5` on PUT, so + * each asset must declare both hashes up front). + * + * The CLI emits `2` going forward. The platform's deploy ingest accepts + * either shape on the multipart endpoint; the JSON-mode (manifest-only) + * deploy endpoint requires `2`. + */ + assetManifestVersion?: 1 | 2; auth?: boolean; bindings: { d1?: Array; diff --git a/packages/void/test/unit/asset-hashing.test.ts b/packages/void/test/unit/asset-hashing.test.ts new file mode 100644 index 000000000..2f892ca84 --- /dev/null +++ b/packages/void/test/unit/asset-hashing.test.ts @@ -0,0 +1,137 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { hash as blake3hash } from 'blake3-jit'; +import { join } from 'pathe'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// `vi.mock` is hoisted above all imports — declare the mock before importing +// `node:fs` consumers so the spy is in place when `package.ts` resolves its +// `import { readFileSync } from 'node:fs'` binding. We re-export every other +// member of the real module via `actual` so existsSync/readdirSync etc. +// continue to work end-to-end against the temp directory. +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + readFileSync: vi.fn(actual.readFileSync), + }; +}); + +const fsModule = await import('node:fs'); +const readFileSyncMock = fsModule.readFileSync as unknown as ReturnType; + +const { collectAndHashAssets, computeAssetHash, computeAssetHashes, computeMd5 } = + await import('../../src/cli/package.ts'); + +const TMP = join(import.meta.dirname, '.tmp-asset-hashing-test'); + +beforeEach(() => { + mkdirSync(TMP, { recursive: true }); + readFileSyncMock.mockClear(); +}); + +afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); +}); + +describe('computeMd5', () => { + it('matches Node crypto.createHash("md5") for raw bytes', () => { + // Sanity: empty string MD5 is the well-known constant. + expect(computeMd5(Buffer.from(''))).toBe('d41d8cd98f00b204e9800998ecf8427e'); + }); + + it('returns 32 lowercase hex chars', () => { + const md5 = computeMd5(Buffer.from('something')); + expect(md5).toHaveLength(32); + expect(md5).toMatch(/^[0-9a-f]{32}$/); + }); + + it('hashes raw content (not base64+ext)', () => { + // MD5 must operate on raw bytes — independent of any extension/base64 + // wrapping that BLAKE3 uses. Pin a known fixture. + expect(computeMd5(Buffer.from('hello\n'))).toBe('b1946ac92492d2347c6235b4d2611184'); + }); +}); + +describe('computeAssetHashes', () => { + it('returns both BLAKE3 and MD5 for known fixtures', () => { + // Fixture: file content "hello\n" with extension "js". + // Expected values pinned from a fresh local computation against + // blake3-jit and node:crypto md5. + const content = Buffer.from('hello\n'); + const filePath = 'test.js'; + const { blake3, md5 } = computeAssetHashes(content, filePath); + expect(blake3).toBe('dc6c72af24e164d6188743458a7da473'); + expect(md5).toBe('b1946ac92492d2347c6235b4d2611184'); + }); + + it('matches the standalone computeAssetHash + computeMd5 outputs', () => { + const content = Buffer.from('some asset bytes'); + const filePath = 'foo/bar.css'; + const both = computeAssetHashes(content, filePath); + expect(both.blake3).toBe(computeAssetHash(content, filePath)); + expect(both.md5).toBe(computeMd5(content)); + }); +}); + +describe('BLAKE3 wire compatibility', () => { + it('matches Wrangler\'s hashFile output for "const x = 1;\\n" / .js', () => { + // Regression test for the existing CF-compatible recipe: + // blake3(base64(content) + extension).hex().slice(0, 32) + // Bit-for-bit compatible with workers-sdk's pages/hash.ts:hashFile. + const content = Buffer.from('const x = 1;\n'); + const filePath = 'foo.js'; + + // Direct computation (independent of computeAssetHash) to lock the + // algorithm in place even if the helper changes shape. + const base64 = content.toString('base64'); + const ext = 'js'; + const input = new TextEncoder().encode(base64 + ext); + const expected = Buffer.from(blake3hash(input)).toString('hex').slice(0, 32); + + expect(expected).toBe('3725f96aa779d1959d5942b1b4e04567'); + expect(computeAssetHash(content, filePath)).toBe(expected); + expect(computeAssetHashes(content, filePath).blake3).toBe(expected); + }); +}); + +describe('collectAndHashAssets — v2 manifest entries', () => { + it('emits { blake3, md5, size, hash } per entry', async () => { + writeFileSync(join(TMP, 'a.js'), 'hello\n'); + + const { assetManifest } = await collectAndHashAssets(TMP); + + const entry = assetManifest['/a.js']; + expect(entry).toBeDefined(); + expect(entry.blake3).toBe('dc6c72af24e164d6188743458a7da473'); + expect(entry.md5).toBe('b1946ac92492d2347c6235b4d2611184'); + expect(entry.size).toBe(Buffer.from('hello\n').length); + // Deprecated v1 alias — must equal blake3 for one release so existing + // consumers reading entry.hash keep working. + expect(entry.hash).toBe(entry.blake3); + }); + + it('reads each file from disk exactly once', async () => { + writeFileSync(join(TMP, 'a.js'), 'one'); + writeFileSync(join(TMP, 'b.css'), 'two'); + writeFileSync(join(TMP, 'c.html'), 'three'); + + // Mock state was cleared in beforeEach; collectAndHashAssets is the only + // call into readFileSync from this point. The mocked fn delegates to the + // real implementation so file content still flows through correctly. + const { assetManifest } = await collectAndHashAssets(TMP); + + // Filter mock calls to just the assets we wrote (createAssetsIgnoreFunction + // does NOT readFileSync .assetsignore unless it exists, but be defensive). + const assetReads = readFileSyncMock.mock.calls.filter(([p]: Array) => { + const path = String(p); + return path.endsWith('a.js') || path.endsWith('b.css') || path.endsWith('c.html'); + }); + expect(assetReads).toHaveLength(3); + // No duplicate path should appear twice — proves single-pass. + const paths = assetReads.map(([p]: Array) => String(p)); + expect(new Set(paths).size).toBe(3); + + // Manifest should still be populated correctly (sanity). + expect(Object.keys(assetManifest).sort()).toEqual(['/a.js', '/b.css', '/c.html']); + }); +}); From a34e73016dd190b3ad21b293fcf7f40d1c60b5fa Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 7 May 2026 19:42:06 +0800 Subject: [PATCH 2/8] feat(cli/deploy): direct-to-R2 upload via presigned URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4 of the direct-to-R2 asset upload series (design doc 0068). Flips the CLI to upload asset bytes directly to R2 via presigned PUT URLs and finalize via the manifest-only JSON deploy endpoint. Removes asset streaming from the API Worker request path so big static deploys stop hitting the CF edge-to-Worker streaming-body timeout. CLI surface: - new client.requestUploadUrls(...) and client.finalizeDeploy(...) on PlatformClient. Both share the same NDJSON-stream-reader as the legacy multipart deploy() so the existing applyDeployEvent machinery works unchanged. - new --legacy-upload flag re-selects the multipart path as a one- release escape hatch; the default flow is now direct-to-R2. - new direct-r2-upload.ts performs the bounded-concurrency PUT pool (10 in flight, 5xx retry up to 3 times with 250/500/1000ms backoff, 4xx surfaced immediately) and tags the JSONL log with per-asset presign_request_*, r2_put_*, and finalize_* events for postmortems. Asset chain double-read fix (Codex Finding #2): - collectAndHashAssets already returned an in-memory buffer cache as part of Task 3; this PR threads it through every uploader. The packageBuild / packageStaticBuild / packageFrameworkBuild functions now accept an optional assetFiles?: Map and consume it via the new appendAssetsFromCache helper instead of re-reading from disk. The legacy multipart and the new direct-to-R2 paths both run with each asset read exactly once, end-to-end. - buildVoidManifest, buildStaticManifest, buildFrameworkManifest, and collectWorkerFiles are factored out as pure / single-pass helpers so the direct-to-R2 path can compute the manifest and worker bytes without rebuilding a FormData first. Tests: - test/unit/direct-r2-client.test.ts — verifies requestUploadUrls POSTs JSON to the right endpoint, finalizeDeploy uses application/ json (NOT multipart), and NDJSON events are parsed correctly across chunk boundaries. - test/unit/direct-r2-upload.test.ts — exercises the PUT pool: 4xx surfaces with path+hash, 5xx retries, network errors retry, bounded concurrency, empty-list short-circuit. - test/integration/direct-r2-deploy.test.ts — drives runStaticDeploy end-to-end against mocked platform + R2, verifies the JSONL log carries all the new events, the failure-mode message ends with the JSONL path, and (Codex Finding #2) each asset file is read from disk EXACTLY ONCE in both the direct-to-R2 and legacy multipart paths. Backward compat: - The multipart client.deploy() path stays unchanged; --legacy-upload exercises it. Old CLIs keep working against the platform's multipart endpoint (no platform-side break in this PR). Out of scope (per design doc 0068): - Removing the multipart endpoint (deferred to a later cleanup PR). - Cross-project content-addressed dedupe (still per-project). Co-Authored-By: Claude Opus 4.7 --- docs/reference/cli.md | 17 +- packages/void/src/cli/args.ts | 5 +- packages/void/src/cli/cli.ts | 1 + packages/void/src/cli/client.ts | 129 +++- packages/void/src/cli/deploy.ts | 511 ++++++++++++---- packages/void/src/cli/direct-r2-upload.ts | 215 +++++++ packages/void/src/cli/help.ts | 5 + packages/void/src/cli/package.ts | 567 +++++++++++++----- .../test/integration/direct-r2-deploy.test.ts | 561 +++++++++++++++++ .../void/test/unit/direct-r2-client.test.ts | 251 ++++++++ .../void/test/unit/direct-r2-upload.test.ts | 163 +++++ 11 files changed, 2153 insertions(+), 272 deletions(-) create mode 100644 packages/void/src/cli/direct-r2-upload.ts create mode 100644 packages/void/test/integration/direct-r2-deploy.test.ts create mode 100644 packages/void/test/unit/direct-r2-client.test.ts create mode 100644 packages/void/test/unit/direct-r2-upload.test.ts diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 245f70513..a674c4db2 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -203,20 +203,21 @@ If `--project` is provided, purges that project's cache instead of the linked pr ### `void deploy` ``` -void deploy [--project ] [--dir ] [--spa] [--skip-build] [--debug] +void deploy [--project ] [--dir ] [--spa] [--skip-build] [--debug] [--legacy-upload] ``` Auto-detects your project type and chooses the right pipeline. See [Supported App Types](../guide/app-types.md) and [Deployment](../guide/deployment.md) for details. For Drizzle projects, deploy performs a read-only schema drift check. If a new migration would be generated, deploy stops and tells you to run `void db generate`, review the migration, commit it yourself, and rerun `void deploy`. -| Flag | Purpose | -| ------------------ | ---------------------------------------------------------------------------- | -| `--project ` | Target a specific project by slug | -| `--dir ` | Deploy a pre-built static directory (skips build) | -| `--spa` | Use SPA mode instead of SSG for static deploys | -| `--skip-build` | Skip the build step (use existing build output) | -| `--debug` | Mirror the structured deploy log to stderr (also written to `~/.void/logs/`) | +| Flag | Purpose | +| ------------------ | ----------------------------------------------------------------------------------------------------------- | +| `--project ` | Target a specific project by slug | +| `--dir ` | Deploy a pre-built static directory (skips build) | +| `--spa` | Use SPA mode instead of SSG for static deploys | +| `--skip-build` | Skip the build step (use existing build output) | +| `--debug` | Mirror the structured deploy log to stderr (also written to `~/.void/logs/`) | +| `--legacy-upload` | Use the legacy multipart upload path. For backward compatibility only; will be removed in a future release. | Every deploy writes a structured JSONL trace to `~/.void/logs/deploy-.jsonl` regardless of `--debug`. On failure the path is printed at the end of the error message so you can attach it when reporting platform issues. `VOID_DEPLOY_DEBUG=1` is accepted as an alternate trigger for stderr mirroring. diff --git a/packages/void/src/cli/args.ts b/packages/void/src/cli/args.ts index d30007ba0..aea246c42 100644 --- a/packages/void/src/cli/args.ts +++ b/packages/void/src/cli/args.ts @@ -6,6 +6,7 @@ export interface DeployArgs { spa?: boolean; skipBuild?: boolean; debug?: boolean; + legacyUpload?: boolean; } export function parseDeployArgs(args: Array): DeployArgs { @@ -19,6 +20,7 @@ export function parseDeployArgs(args: Array): DeployArgs { spa: { type: 'boolean' }, 'skip-build': { type: 'boolean' }, debug: { type: 'boolean' }, + 'legacy-upload': { type: 'boolean' }, }, allowPositionals: true, strict: true, @@ -61,6 +63,7 @@ export function parseDeployArgs(args: Array): DeployArgs { const spa = parsed.values.spa === true ? true : undefined; const skipBuild = parsed.values['skip-build'] === true ? true : undefined; const debug = parsed.values.debug === true ? true : undefined; + const legacyUpload = parsed.values['legacy-upload'] === true ? true : undefined; - return { projectSlug, dir, spa, skipBuild, debug }; + return { projectSlug, dir, spa, skipBuild, debug, legacyUpload }; } diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index 9630068b8..ddf83a242 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -99,6 +99,7 @@ if (helpPath) { spa: deployArgs.spa, skipBuild: deployArgs.skipBuild, debug: deployArgs.debug, + legacyUpload: deployArgs.legacyUpload, }).catch((err) => { log.error(err.message ?? err); process.exit(1); diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index f4c4696cb..ba87972b8 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -2,7 +2,8 @@ import { cfAccessHeaders } from '../shared/cf-access.ts'; import { getApiUrl } from './auth.ts'; import { ensureCloudflaredToken } from './cf-access.ts'; import type { CliLogger } from './log-file.ts'; -import type { DeployEvent } from './types.ts'; +import type { AssetManifestEntry } from './package.ts'; +import type { DeployEvent, DeployManifest } from './types.ts'; export type PlatformErrorPayload = { error?: string; @@ -117,6 +118,44 @@ export interface DomainStatusResponse extends DomainResponse { verification_errors?: Array; } +/** + * Asset declaration shape for `POST /:id/deploy/upload-urls`. Each entry + * carries both hashes the platform needs: + * - `blake3` is the storage key (`assets/${projectId}/${blake3}`) + * - `md5` is the wire-level upload-integrity primitive — the platform + * bakes it into the presigned PUT URL as a SIGNED `Content-Md5` + * header so R2 enforces integrity at write time. + */ +export interface UploadUrlAssetRequest { + blake3: string; + md5: string; + size: number; + contentType: string; +} + +/** + * Manifest-only (direct-to-R2) deploy payload. Sent as JSON to + * `POST /:id/deploy` with `Content-Type: application/json`. Mirrors the + * `handleManifestOnlyDeploy` server contract documented in design doc 0068. + * + * Asset bytes are NOT carried in this payload — they were uploaded directly + * to R2 via presigned PUT URLs before this call. The server verifies each + * declared asset key exists in R2 (parallel HEAD) before flipping routing. + */ +export interface ManifestOnlyDeployPayload { + manifest: DeployManifest; + assetManifest: Record; + source: string | null; + commit: string | null; + repositoryUrl: string | null; + commitUrl: string | null; + gitRef: string | null; + preflight: 'true' | null; + vars?: Record; + /** Worker module bytes keyed by relative path, base64-encoded. */ + workerFiles?: Record; +} + /** * HTTP client for the Void platform API. */ @@ -288,19 +327,91 @@ export class PlatformClient { } async *deploy(projectId: string, formData: FormData): AsyncGenerator { - const cliLog = this.cliLog; const url = `${this.baseUrl}/projects/${projectId}/deploy`; + yield* this.streamDeployRequest(url, { + method: 'POST', + headers: { + ...this.authHeaders, + }, + body: formData, + }); + } + + /** + * Request presigned R2 PUT URLs for a batch of assets. Each entry carries + * both `blake3` (storage key) and `md5` (wire-level integrity primitive) + * so the platform can sign a PUT URL that R2 enforces against the bytes + * the CLI is about to upload. See design doc 0068 for the full protocol. + */ + async requestUploadUrls( + projectId: string, + assets: Array, + ): Promise }>> { + const url = `${this.baseUrl}/projects/${projectId}/deploy/upload-urls`; + this.cliLog?.info('upload_urls_post', { url, assetCount: assets.length }); + + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.authHeaders, + }, + body: JSON.stringify({ assets }), + }); + + const cfRay = res.headers.get('cf-ray'); + const cfCacheStatus = res.headers.get('cf-cache-status'); + this.cliLog?.info('upload_urls_response', { + status: res.status, + cfRay, + cfCacheStatus, + }); + + if (!res.ok) { + await throwPlatformApiError('Failed to request upload URLs', res); + } + + const json = (await res.json()) as { + urls: Record }>; + }; + return json.urls; + } + + /** + * Manifest-only (direct-to-R2) deploy. POSTs a JSON body containing the + * deploy manifest, asset manifest (no asset bytes), and any worker module + * bytes (base64-encoded). Streams the same NDJSON response shape as the + * legacy multipart `deploy(...)` path, so the caller can apply events with + * the existing `applyDeployEvent` machinery. + */ + async *finalizeDeploy( + projectId: string, + payload: ManifestOnlyDeployPayload, + ): AsyncGenerator { + const url = `${this.baseUrl}/projects/${projectId}/deploy`; + yield* this.streamDeployRequest(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.authHeaders, + }, + body: JSON.stringify(payload), + }); + } + + /** + * Shared POST + NDJSON-stream-reader for the multipart `deploy()` and the + * JSON-mode `finalizeDeploy()` paths. Identical wire framing on both, so + * the loop is factored here once. Captures `cf-ray` + `cf-cache-status` + * for postmortem correlation. + */ + private async *streamDeployRequest(url: string, init: RequestInit): AsyncGenerator { + const cliLog = this.cliLog; cliLog?.info('deploy_post', { url }); let res: Response; try { - res = await fetch(url, { - method: 'POST', - headers: { - ...this.authHeaders, - }, - body: formData, - }); + res = await fetch(url, init); } catch (err) { cliLog?.error('deploy_fetch_rejected', err, { url }); throw err; diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index a9799c592..db27de101 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -51,13 +51,14 @@ import { import { resolveRemoteMode, stripInternalEnvKeys } from '../shared/env.ts'; import { validateSsrEntry } from '../ssr/entry.ts'; import { getToken, getTokenSource } from './auth.ts'; -import { isExpiredTokenError, PlatformClient } from './client.ts'; +import { isExpiredTokenError, PlatformClient, type ManifestOnlyDeployPayload } from './client.ts'; import { promptAndCreateProject } from './create-project.ts'; import { formatConnectionLostMessage, formatDeployFailureMessage, formatStreamDeployError, } from './deploy-errors.ts'; +import { uploadAssetsToR2, type DirectR2PutItem } from './direct-r2-upload.ts'; import { fetchRemoteSecretNames, formatEnvReport, @@ -74,6 +75,12 @@ import { packageStaticBuild, packageFrameworkBuild, collectAndHashAssets, + buildVoidManifest, + buildStaticManifest, + buildFrameworkManifest, + collectWorkerFiles, + getAssetContentType, + type AssetManifestEntry, } from './package.ts'; import { detectPreset, @@ -86,7 +93,13 @@ import { import { readProjectConfig, writeProjectConfig, type ProjectConfig } from './project.ts'; import { promptProjectSelection, promptProjectSetupAction } from './prompt.ts'; import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project.ts'; -import type { AssetConfig, DeployEvent, DevRedirectRule, HeaderRule } from './types.ts'; +import type { + AssetConfig, + DeployEvent, + DeployManifest, + DevRedirectRule, + HeaderRule, +} from './types.ts'; import { readWranglerCompat, type WranglerCompat } from './wrangler.ts'; import { needsWrapper, generateWrapper, bundleEntry, cleanupWrapper } from './wrapper.ts'; @@ -99,6 +112,13 @@ export interface RunDeployOptions { spa?: boolean; skipBuild?: boolean; debug?: boolean; + /** + * When true, the CLI uses the legacy multipart `POST /:id/deploy` upload + * path instead of the default direct-to-R2 + JSON-mode finalize flow. + * Kept as an escape hatch for one release while the new path beds in; + * scheduled for removal once direct-to-R2 is proven on production traffic. + */ + legacyUpload?: boolean; } type DeployDoneEvent = Extract; @@ -301,6 +321,7 @@ async function runDeployInner( options?.skipBuild, gitMetadata, cliLog, + options?.legacyUpload, ); } else { // Class A: preset is optional (e.g. vinext probes at runtime) @@ -313,6 +334,7 @@ async function runDeployInner( gitMetadata, detected, cliLog, + options?.legacyUpload, ); } return; @@ -371,6 +393,7 @@ async function runDeployInner( undefined, voidConfig.routing, cliLog, + options?.legacyUpload, ); } else { await runFullDeploy( @@ -382,6 +405,7 @@ async function runDeployInner( gitMetadata, undefined, cliLog, + options?.legacyUpload, ); } return; @@ -510,10 +534,16 @@ export function applyDeployEvent(event: DeployEvent, s: SpinnerResult): ApplyDep return { kind: 'continue' }; } -async function streamDeploy( - client: PlatformClient, +/** + * Apply a streamed `DeployEvent` iterator to the spinner / cliLog and + * resolve with the terminal `done` event (or throw on terminal error / + * connection loss). Path-agnostic: the legacy multipart deploy and the + * new direct-to-R2 finalize call both produce the same NDJSON event + * stream, so this loop is shared between them. + */ +async function streamDeployEvents( projectId: string, - formData: FormData, + events: AsyncGenerator, s: SpinnerResult, cliLog?: CliLogger, ): Promise { @@ -541,7 +571,7 @@ async function streamDeploy( process.once('SIGINT', onSigint); try { - for await (const event of client.deploy(projectId, formData)) { + for await (const event of events) { const result = applyDeployEvent(event, s); if (result.kind === 'start') { deploymentId = result.deploymentId; @@ -584,6 +614,147 @@ async function streamDeploy( throw new Error(formatConnectionLostMessage(deploymentId, cliLog?.path ?? null)); } +async function streamDeploy( + client: PlatformClient, + projectId: string, + formData: FormData, + s: SpinnerResult, + cliLog?: CliLogger, +): Promise { + return streamDeployEvents(projectId, client.deploy(projectId, formData), s, cliLog); +} + +/** + * Direct-to-R2 deploy orchestration. Issues presigned PUT URLs for each + * needed asset, uploads bytes directly to R2, then calls the manifest-only + * deploy endpoint to finalize. Returns the same `StreamDeployResult` shape + * as the legacy multipart `streamDeploy` so callers downstream stay + * path-agnostic. + * + * Key invariants: + * 1. No needed assets → skip presign + R2 PUT phase entirely (saves a + * round-trip when every asset was already present from a prior deploy). + * 2. Asset bytes come exclusively from the in-memory `assetFiles` cache + * computed by `collectAndHashAssets` — files are NEVER re-read from + * disk during this flow (Codex Finding #2 in design doc 0068 review). + * 3. R2 PUT failures (4xx, 5xx, network) are surfaced with the asset + * path + hash so the user can correlate against the JSONL trace. + */ +async function streamDirectR2Deploy( + client: PlatformClient, + projectId: string, + manifest: DeployManifest, + assetManifest: Record, + assetFiles: Map, + needed: Array, + gitMetadata: GitDeployMetadata | undefined, + preflightUsed: boolean, + workerFiles: Map | undefined, + s: SpinnerResult, + cliLog?: CliLogger, +): Promise { + if (needed.length > 0) { + s.message(`Requesting upload URLs for ${needed.length} asset(s)...`); + + const assetsToUpload = needed.map((path) => { + const entry = assetManifest[path]; + if (!entry) { + // Should never fire — `needed` is preflight's projection of + // assetManifest. Defensive throw makes the cause obvious if a + // future preflight regression slips through. + throw new Error(`internal: preflight returned ${path} but assetManifest has no entry`); + } + return { + blake3: entry.blake3, + md5: entry.md5, + size: entry.size, + contentType: getAssetContentType(path), + }; + }); + + cliLog?.info('presign_request_start', { assetCount: assetsToUpload.length }); + const presignStartedAt = Date.now(); + const urls = await client.requestUploadUrls(projectId, assetsToUpload); + cliLog?.info('presign_request_end', { + urlsReceived: Object.keys(urls).length, + durationMs: Date.now() - presignStartedAt, + }); + + // Map needed paths → DirectR2PutItem, with the asset bytes pulled from + // the in-memory cache (no disk reads here). + const items: Array = needed.map((path) => { + const entry = assetManifest[path]; + const presigned = urls[entry.blake3]; + if (!presigned) { + throw new Error( + `Platform did not return an upload URL for ${path} (blake3=${entry.blake3}). ` + + `This indicates a server-side bug or a stale client.`, + ); + } + const body = assetFiles.get(path); + if (!body) { + // Defensive — `needed` came from `assetManifest`, which was + // constructed from `assetFiles`. If this fires, the upstream + // collection diverged from preflight. + throw new Error( + `internal: asset ${path} declared in manifest but missing from buffer cache`, + ); + } + return { + path, + hash: entry.blake3, + body, + url: presigned.url, + headers: presigned.headers, + }; + }); + + s.message(`Uploading ${items.length} asset(s) to R2...`); + await uploadAssetsToR2(items, cliLog); + } else { + cliLog?.info('presign_skipped', { reason: 'no_needed_assets' }); + } + + // Build the JSON-mode payload. Worker bytes go in as base64 — the + // manifest-only endpoint's design (0068) keeps worker bundles in the + // body since they are bounded at 32 MiB total. + const payload: ManifestOnlyDeployPayload = { + manifest, + assetManifest, + source: getTokenSource(), + commit: gitMetadata?.commit ?? null, + repositoryUrl: gitMetadata?.repositoryUrl ?? null, + commitUrl: gitMetadata?.commitUrl ?? null, + gitRef: gitMetadata?.gitRef ?? null, + preflight: preflightUsed ? 'true' : null, + }; + if (workerFiles && workerFiles.size > 0) { + const encoded: Record = {}; + for (const [relPath, buf] of workerFiles) { + encoded[relPath] = buf.toString('base64'); + } + payload.workerFiles = encoded; + } + + s.message('Finalizing deploy...'); + cliLog?.info('finalize_start', { + workerFileCount: workerFiles?.size ?? 0, + assetCount: Object.keys(assetManifest).length, + }); + const finalizeStartedAt = Date.now(); + + try { + return await streamDeployEvents( + projectId, + client.finalizeDeploy(projectId, payload), + s, + cliLog, + ); + } finally { + cliLog?.info('finalize_end', { durationMs: Date.now() - finalizeStartedAt }); + } +} + function formatKnownAssetSummary(result: DeployDoneEvent, skipped: number): string { const uploaded = result.assets - skipped; return skipped > 0 @@ -607,7 +778,7 @@ function appendDeployMetadata(formData: FormData, gitMetadata?: GitDeployMetadat } } -async function runStaticDeploy( +export async function runStaticDeploy( root: string, config: ProjectConfig, client: PlatformClient, @@ -620,6 +791,7 @@ async function runStaticDeploy( fallbackRules?: Array, routing?: RoutingConfig, cliLog?: CliLogger, + legacyUpload?: boolean, ): Promise { const typeLabel = preset.appType === 'spa' ? 'Static SPA' : 'Static Site'; log.info(`${typeLabel} deploy`); @@ -656,10 +828,13 @@ async function runStaticDeploy( const s = createSpinner(); const onProgress = (msg: string) => s.message(msg); - // Preflight: hash assets and check which ones changed + // Preflight: hash assets and check which ones changed. + // `assetFiles` carries the in-memory buffer cache so the upload step + // (direct-to-R2 or legacy multipart) consumes the same bytes that were + // hashed — eliminates the chain double-read flagged in design doc 0068. s.start('Checking for changes...'); cliLog?.info('preflight_start', { dir: preset.outputDir }); - const { assetManifest } = await collectAndHashAssets(preset.outputDir, onProgress); + const { assetManifest, assetFiles } = await collectAndHashAssets(preset.outputDir, onProgress); const { needed, skipped } = await client.preflight(config.projectId, assetManifest); s.stop('Checked for changes'); cliLog?.info('preflight_end', { @@ -673,27 +848,56 @@ async function runStaticDeploy( } s.start('Packaging...'); - cliLog?.info('package_start', { mode: 'static' }); + cliLog?.info('package_start', { mode: 'static', legacyUpload: legacyUpload === true }); const packageStartedAt = Date.now(); const neededSet = skipped > 0 ? new Set(needed) : null; - const formData = await packageStaticBuild( - preset.outputDir, - preset.appType, - neededSet, - hashedAssetsPrefix ?? 'assets', - headerRules, - redirectRules, - fallbackRules, - onProgress, - ); - formData.append('assetManifest', JSON.stringify(assetManifest)); - appendDeployMetadata(formData, gitMetadata); - cliLog?.info('package_end', { - mode: 'static', - durationMs: Date.now() - packageStartedAt, - }); - const result = await streamDeploy(client, config.projectId, formData, s, cliLog); + let result: StreamDeployResult; + if (legacyUpload) { + const formData = await packageStaticBuild( + preset.outputDir, + preset.appType, + neededSet, + hashedAssetsPrefix ?? 'assets', + headerRules, + redirectRules, + fallbackRules, + onProgress, + assetFiles, + ); + formData.append('assetManifest', JSON.stringify(assetManifest)); + appendDeployMetadata(formData, gitMetadata); + cliLog?.info('package_end', { + mode: 'static', + durationMs: Date.now() - packageStartedAt, + }); + result = await streamDeploy(client, config.projectId, formData, s, cliLog); + } else { + const manifest = buildStaticManifest({ + appType: preset.appType, + hashedAssetsPrefix: hashedAssetsPrefix ?? 'assets', + headerRules, + redirectRules, + fallbackRules, + }); + cliLog?.info('package_end', { + mode: 'static', + durationMs: Date.now() - packageStartedAt, + }); + result = await streamDirectR2Deploy( + client, + config.projectId, + manifest, + assetManifest, + assetFiles, + needed, + gitMetadata, + neededSet !== null, + undefined, + s, + cliLog, + ); + } const summary = [`${typeLabel} — ${formatKnownAssetSummary(result, skipped)}`]; note(summary.join('\n'), result.url); @@ -730,6 +934,7 @@ async function runFrameworkDeploy( skipBuild?: boolean, gitMetadata?: GitDeployMetadata, cliLog?: CliLogger, + legacyUpload?: boolean, ): Promise { log.info(`${detected.name} framework deploy`); cliLog?.info('deploy_mode', { mode: 'framework', framework: detected.name }); @@ -924,10 +1129,12 @@ async function runFrameworkDeploy( const s = createSpinner(); const onProgress = (msg: string) => s.message(msg); - // Preflight: hash assets and check which ones changed + // Preflight: hash assets and check which ones changed. + // `assetFiles` is threaded through to the upload step so each asset is + // read from disk once, end-to-end (Codex Finding #2 in design doc 0068). s.start('Checking for changes...'); cliLog?.info('preflight_start', { dir: assetsDir }); - const { assetManifest } = await collectAndHashAssets(assetsDir, onProgress, { + const { assetManifest, assetFiles } = await collectAndHashAssets(assetsDir, onProgress, { ignorePatterns: frameworkAssetIgnorePatterns, }); const { needed, skipped } = await client.preflight(config.projectId, assetManifest, true); @@ -943,46 +1150,99 @@ async function runFrameworkDeploy( } s.start('Packaging...'); - cliLog?.info('package_start', { mode: 'framework', framework: detected.name }); + cliLog?.info('package_start', { + mode: 'framework', + framework: detected.name, + legacyUpload: legacyUpload === true, + }); const packageStartedAt = Date.now(); const neededSet = skipped > 0 ? new Set(needed) : null; - const formData = await packageFrameworkBuild({ - frameworkName: detected.name, - workerDir: actualWorkerDir, + const packageOptions = { + bindingNames: resolveBindingNames(voidConfig.inference?.bindings), workerMain: actualWorkerMain, - assetsDir, - bindings, - migrations: validatedMigrations, - schedules, - queues, - revalidate, - prerender, - assetConfig: { not_found_handling: 'none' }, - vars: envVars, - packageOptions: { - bindingNames: resolveBindingNames(voidConfig.inference?.bindings), + compatibilityDate: wranglerCompat.compatibilityDate, + compatibilityFlags: wranglerCompat.compatibilityFlags, + }; + + let result: StreamDeployResult; + if (legacyUpload) { + const formData = await packageFrameworkBuild({ + frameworkName: detected.name, + workerDir: actualWorkerDir, workerMain: actualWorkerMain, - compatibilityDate: wranglerCompat.compatibilityDate, - compatibilityFlags: wranglerCompat.compatibilityFlags, - }, - revalidateQueryAllowlist: voidConfig.routing?.revalidateQueryAllowlist, - neededAssets: neededSet, - hashedAssetsPrefix: fwPreset.hashedAssetsPrefix ?? 'assets', - headerRules, - redirectRules, - fallbackRules, - dialect, - assetIgnorePatterns: frameworkAssetIgnorePatterns, - onProgress, - }); - formData.append('assetManifest', JSON.stringify(assetManifest)); - appendDeployMetadata(formData, gitMetadata); - cliLog?.info('package_end', { - mode: 'framework', - durationMs: Date.now() - packageStartedAt, - }); + assetsDir, + bindings, + migrations: validatedMigrations, + schedules, + queues, + revalidate, + prerender, + assetConfig: { not_found_handling: 'none' }, + vars: envVars, + packageOptions, + revalidateQueryAllowlist: voidConfig.routing?.revalidateQueryAllowlist, + neededAssets: neededSet, + hashedAssetsPrefix: fwPreset.hashedAssetsPrefix ?? 'assets', + headerRules, + redirectRules, + fallbackRules, + dialect, + assetIgnorePatterns: frameworkAssetIgnorePatterns, + onProgress, + assetFiles, + }); + formData.append('assetManifest', JSON.stringify(assetManifest)); + appendDeployMetadata(formData, gitMetadata); + cliLog?.info('package_end', { + mode: 'framework', + durationMs: Date.now() - packageStartedAt, + }); + result = await streamDeploy(client, config.projectId, formData, s, cliLog); + } else { + const manifest = buildFrameworkManifest({ + frameworkName: detected.name, + workerMain: actualWorkerMain, + bindings, + migrations: validatedMigrations, + schedules, + queues, + revalidate, + revalidateQueryAllowlist: voidConfig.routing?.revalidateQueryAllowlist, + prerender, + assetConfig: { not_found_handling: 'none' }, + vars: envVars, + packageOptions, + hashedAssetsPrefix: fwPreset.hashedAssetsPrefix ?? 'assets', + headerRules, + redirectRules, + fallbackRules, + dialect, + }); + const workerFiles = collectWorkerFiles(actualWorkerDir); + cliLog?.info('package_end', { + mode: 'framework', + durationMs: Date.now() - packageStartedAt, + }); - const result = await streamDeploy(client, config.projectId, formData, s, cliLog); + // Migration SQL bytes ride along inside the worker bundle (compiled in + // by the framework via `migration-handler.ts`); the deploy body only + // needs `manifest.migrations.pending` so the platform knows to invoke + // the worker's `/__void/migrate` endpoint after dispatch. So no special + // handling is needed for migrations on the direct-to-R2 path. + result = await streamDirectR2Deploy( + client, + config.projectId, + manifest, + assetManifest, + assetFiles, + needed, + gitMetadata, + neededSet !== null, + workerFiles, + s, + cliLog, + ); + } const summary = [ `${result.workers} worker module(s), ${formatKnownAssetSummary(result, skipped)}`, @@ -1024,6 +1284,7 @@ async function runFullDeploy( gitMetadata?: GitDeployMetadata, detected?: DetectedFramework, cliLog?: CliLogger, + legacyUpload?: boolean, ): Promise { cliLog?.info('deploy_mode', { mode: 'full', @@ -1277,6 +1538,7 @@ async function runFullDeploy( fallbackRules, undefined, cliLog, + legacyUpload, ); } } @@ -1340,10 +1602,12 @@ async function runFullDeploy( authEnabled, }); - // Preflight: hash assets and check which ones changed + // Preflight: hash assets and check which ones changed. + // `assetFiles` carries the in-memory buffer cache through to the upload + // step so each asset is read from disk exactly once (Codex Finding #2). s.start('Checking for changes...'); cliLog?.info('preflight_start', { dir: clientDir }); - const { assetManifest } = await collectAndHashAssets(clientDir, onProgress); + const { assetManifest, assetFiles } = await collectAndHashAssets(clientDir, onProgress); const { needed, skipped } = await client.preflight(config.projectId, assetManifest, true); s.stop('Checked for changes'); cliLog?.info('preflight_end', { @@ -1365,45 +1629,90 @@ async function runFullDeploy( hasWebSockets, authEnabled, framework: detected?.name, + legacyUpload: legacyUpload === true, }); const packageStartedAt = Date.now(); const neededSet = skipped > 0 ? new Set(needed) : null; - const formData = await packageBuild( - distDir, - workerDirName, - effectiveBindings, - validatedMigrations, - schedules, - isSsr || hasRoutes || hasMiddleware || hasWebSockets || authEnabled, - isFrameworkMode ? detected!.name : undefined, - revalidate, - envVars, - queues, - uniquePrerenderPaths, - assetConfig, - { - compatibilityDate: wranglerCompat?.compatibilityDate, - compatibilityFlags: wranglerCompat?.compatibilityFlags, - ...(sandboxConfig && { sandbox: sandboxConfig }), - revalidateQueryAllowlist: deployConfig.routing?.revalidateQueryAllowlist, - }, - neededSet, - assetsPrefix, - headerRules, - redirectRules, - fallbackRules, - dialect, - webSockets, - onProgress, - ); - formData.append('assetManifest', JSON.stringify(assetManifest)); - appendDeployMetadata(formData, gitMetadata); - cliLog?.info('package_end', { - mode: 'full', - durationMs: Date.now() - packageStartedAt, - }); + const voidPackageOptions = { + compatibilityDate: wranglerCompat?.compatibilityDate, + compatibilityFlags: wranglerCompat?.compatibilityFlags, + ...(sandboxConfig && { sandbox: sandboxConfig }), + revalidateQueryAllowlist: deployConfig.routing?.revalidateQueryAllowlist, + }; - const result = await streamDeploy(client, config.projectId, formData, s, cliLog); + let result: StreamDeployResult; + if (legacyUpload) { + const formData = await packageBuild( + distDir, + workerDirName, + effectiveBindings, + validatedMigrations, + schedules, + isSsr || hasRoutes || hasMiddleware || hasWebSockets || authEnabled, + isFrameworkMode ? detected!.name : undefined, + revalidate, + envVars, + queues, + uniquePrerenderPaths, + assetConfig, + voidPackageOptions, + neededSet, + assetsPrefix, + headerRules, + redirectRules, + fallbackRules, + dialect, + webSockets, + onProgress, + assetFiles, + ); + formData.append('assetManifest', JSON.stringify(assetManifest)); + appendDeployMetadata(formData, gitMetadata); + cliLog?.info('package_end', { + mode: 'full', + durationMs: Date.now() - packageStartedAt, + }); + result = await streamDeploy(client, config.projectId, formData, s, cliLog); + } else { + const manifest = buildVoidManifest({ + bindings: effectiveBindings, + migrations: validatedMigrations, + schedules, + ssr: isSsr || hasRoutes || hasMiddleware || hasWebSockets || authEnabled, + framework: isFrameworkMode ? detected!.name : undefined, + revalidate, + vars: envVars, + queues, + prerender: uniquePrerenderPaths, + assetConfig, + options: voidPackageOptions, + hashedAssetsPrefix: assetsPrefix, + headerRules, + redirectRules, + fallbackRules, + dialect, + webSockets, + onProgress, + }); + const workerFiles = collectWorkerFiles(join(distDir, workerDirName)); + cliLog?.info('package_end', { + mode: 'full', + durationMs: Date.now() - packageStartedAt, + }); + result = await streamDirectR2Deploy( + client, + config.projectId, + manifest, + assetManifest, + assetFiles, + needed, + gitMetadata, + neededSet !== null, + workerFiles, + s, + cliLog, + ); + } const summary = [ `${result.workers} worker module(s), ${formatKnownAssetSummary(result, skipped)}`, diff --git a/packages/void/src/cli/direct-r2-upload.ts b/packages/void/src/cli/direct-r2-upload.ts new file mode 100644 index 000000000..25456575f --- /dev/null +++ b/packages/void/src/cli/direct-r2-upload.ts @@ -0,0 +1,215 @@ +import type { CliLogger } from './log-file.ts'; + +/** + * Concurrency for direct-to-R2 PUTs from the CLI. Bounded to 10 so we don't + * fan out unboundedly into the user's outbound bandwidth — empirically the + * sweet spot for typical asset bundles on residential connections. Adjust + * if real-world traffic patterns warrant a change. + */ +export const R2_PUT_CONCURRENCY = 10; + +/** + * Maximum retry attempts for a single R2 PUT before giving up. Matched to + * the design doc 0068 spec: 5xx triggers retry, 4xx surfaces immediately. + */ +const MAX_R2_PUT_RETRIES = 3; + +/** + * Retry backoff delays in milliseconds. After attempt `i` fails (5xx) we + * sleep `RETRY_BACKOFF_MS[i]` before retrying. Indexed from 0; once the + * array is exhausted we surface the error. + */ +const RETRY_BACKOFF_MS = [250, 500, 1000]; + +export class DirectR2PutError extends Error { + status: number; + hash: string; + path: string; + /** + * `body` is the response body text (may be empty / non-text). Useful in + * the surface error so users can correlate against R2-side errors like + * `BadDigest` (Content-MD5 mismatch) or signature errors. + */ + body: string; + + constructor(status: number, hash: string, path: string, body: string) { + super(`R2 PUT failed for ${path} (${hash}): ${status}${body ? ` ${body}` : ''}`); + this.name = 'DirectR2PutError'; + this.status = status; + this.hash = hash; + this.path = path; + this.body = body; + } +} + +export interface DirectR2PutItem { + /** Asset path relative to the build output (with leading `/`). */ + path: string; + /** BLAKE3 storage hash — used for log correlation and R2 key. */ + hash: string; + /** Asset bytes. */ + body: Buffer; + /** Presigned URL from `requestUploadUrls`. */ + url: string; + /** Headers the platform required us to send (incl. Content-Md5, Content-Type). */ + headers: Record; +} + +/** + * Issue direct-to-R2 PUTs for every item in `items`, bounded to + * `R2_PUT_CONCURRENCY`. Surfaces the first failure (4xx immediately, 5xx + * after retry exhaustion) as a `DirectR2PutError` carrying the asset path + * and hash so the user can correlate against the JSONL log. + * + * Logs `r2_put_start` / `r2_put_end` per asset, plus `r2_put_retry` on + * each retry attempt. Per-asset durations help identify slow uploads. + */ +export async function uploadAssetsToR2( + items: Array, + cliLog: CliLogger | undefined, + fetchImpl: typeof fetch = fetch, +): Promise { + if (items.length === 0) { + return; + } + + let firstError: unknown = null; + let nextIndex = 0; + + // Bounded-pool worker pattern: each worker pulls the next index until + // exhausted or a sibling worker captured an error. We surface the + // first error so concurrent failures don't race the message. + const workerCount = Math.min(R2_PUT_CONCURRENCY, items.length); + const workers: Array> = []; + for (let w = 0; w < workerCount; w++) { + workers.push( + (async () => { + while (firstError === null) { + const i = nextIndex++; + if (i >= items.length) { + return; + } + try { + await uploadOne(items[i], cliLog, fetchImpl); + } catch (err) { + if (firstError === null) { + firstError = err; + } + return; + } + } + })(), + ); + } + await Promise.all(workers); + if (firstError !== null) { + throw firstError; + } +} + +async function uploadOne( + item: DirectR2PutItem, + cliLog: CliLogger | undefined, + fetchImpl: typeof fetch, +): Promise { + const startedAt = Date.now(); + cliLog?.info('r2_put_start', { + hash: item.hash, + path: item.path, + sizeBytes: item.body.length, + }); + + let attempt = 0; + while (true) { + let res: Response; + try { + res = await fetchImpl(item.url, { + method: 'PUT', + // Clone the buffer into a fresh Uint8Array view so each retry sees + // an independent body. Reusing the same Buffer across retries is + // safe with `fetch` (it consumes once), but explicit here. + body: new Uint8Array(item.body) as unknown as BodyInit, + headers: item.headers, + }); + } catch (err) { + // Network-level error: behave like a 5xx for retry purposes (transient + // by nature) but exhaustion still throws. + if (attempt < MAX_R2_PUT_RETRIES) { + cliLog?.warn('r2_put_retry', { + hash: item.hash, + path: item.path, + attempt: attempt + 1, + status: 0, + error: err instanceof Error ? err.message : String(err), + }); + await sleep(RETRY_BACKOFF_MS[attempt]); + attempt++; + continue; + } + cliLog?.error('r2_put_end', err, { + hash: item.hash, + path: item.path, + durationMs: Date.now() - startedAt, + status: 0, + }); + throw new DirectR2PutError( + 0, + item.hash, + item.path, + err instanceof Error ? err.message : String(err), + ); + } + + if (res.ok) { + cliLog?.info('r2_put_end', { + hash: item.hash, + path: item.path, + durationMs: Date.now() - startedAt, + status: res.status, + }); + return; + } + + // Drain the body so the connection is reusable. Truncate for log + // correlation — full R2 error bodies can be verbose. + const errBody = await safeReadText(res); + + if (res.status >= 500 && attempt < MAX_R2_PUT_RETRIES) { + cliLog?.warn('r2_put_retry', { + hash: item.hash, + path: item.path, + attempt: attempt + 1, + status: res.status, + body: truncate(errBody), + }); + await sleep(RETRY_BACKOFF_MS[attempt]); + attempt++; + continue; + } + + cliLog?.error('r2_put_end', undefined, { + hash: item.hash, + path: item.path, + durationMs: Date.now() - startedAt, + status: res.status, + body: truncate(errBody), + }); + throw new DirectR2PutError(res.status, item.hash, item.path, truncate(errBody)); + } +} + +async function safeReadText(res: Response): Promise { + try { + return await res.text(); + } catch { + return ''; + } +} + +function truncate(s: string): string { + return s.length > 500 ? `${s.slice(0, 500)}...` : s; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 279541097..0e95194e5 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -47,6 +47,11 @@ const HELP_TREE: HelpTree = { command: '--debug', description: 'Mirror the deploy log to stderr (always written to ~/.void/logs)', }, + { + command: '--legacy-upload', + description: + 'Use the legacy multipart upload path. For backward compatibility only; will be removed in a future release.', + }, ], examples: [ { command: 'void deploy', description: 'Deploy the linked project' }, diff --git a/packages/void/src/cli/package.ts b/packages/void/src/cli/package.ts index 960bf1c18..ec0696e32 100644 --- a/packages/void/src/cli/package.ts +++ b/packages/void/src/cli/package.ts @@ -152,6 +152,60 @@ export function computeAssetHash(content: Buffer, filePath: string): string { return Buffer.from(blake3hash(input)).toString('hex').slice(0, 32); } +/** + * Map of common asset file extensions → content type. Mirrors + * `packages/dispatch/src/static.ts`'s MIME_TYPES table so the value the CLI + * declares for the R2 PUT signature matches what dispatch will serve later + * (dispatch reads `obj.httpMetadata?.contentType` first, falling back to + * path-based inference). Keeping the tables aligned avoids a needless + * mismatch where R2-stored content-type and serve-time content-type + * disagree on the same asset. + */ +const ASSET_MIME_TYPES: Record = { + html: 'text/html; charset=utf-8', + htm: 'text/html; charset=utf-8', + css: 'text/css; charset=utf-8', + js: 'text/javascript; charset=utf-8', + mjs: 'text/javascript; charset=utf-8', + json: 'application/json; charset=utf-8', + xml: 'application/xml; charset=utf-8', + svg: 'image/svg+xml; charset=utf-8', + txt: 'text/plain; charset=utf-8', + csv: 'text/csv; charset=utf-8', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + ico: 'image/x-icon', + webp: 'image/webp', + avif: 'image/avif', + woff: 'font/woff', + woff2: 'font/woff2', + ttf: 'font/ttf', + otf: 'font/otf', + eot: 'application/vnd.ms-fontobject', + pdf: 'application/pdf', + wasm: 'application/wasm', + webmanifest: 'application/manifest+json; charset=utf-8', + map: 'application/json; charset=utf-8', + mp4: 'video/mp4', + webm: 'video/webm', + mp3: 'audio/mpeg', + ogg: 'audio/ogg', +}; + +/** + * Resolve the content-type to declare on a direct-to-R2 PUT for `path`. + * The value is part of the signed Content-Type header on the presigned + * URL — if the bytes are uploaded with a different content-type, R2 + * rejects the PUT (signature mismatch). Defaults to + * `application/octet-stream` for unknown extensions. + */ +export function getAssetContentType(path: string): string { + const ext = path.split('.').pop()?.toLowerCase() ?? ''; + return ASSET_MIME_TYPES[ext] ?? 'application/octet-stream'; +} + /** * Compute MD5 of raw content as a 32-char lowercase hex string. * @@ -387,32 +441,55 @@ function toSandboxManifest(sandbox: ResolvedSandboxConfig): SandboxManifestMeta }; } -export async function packageBuild( - distDir: string, - workerDirName: string, - bindings: InferredBindings, - migrations?: Array, - schedules?: Array, - ssr?: boolean, - framework?: string, - revalidate?: number | Record, - vars?: Record, - queues?: Array, - prerender?: Array, - assetConfig?: AssetConfig, - options?: PackageOptions, - neededAssets?: Set | null, - hashedAssetsPrefix?: string, - headerRules?: Array, - redirectRules?: Array, - fallbackRules?: Array, - dialect?: 'sqlite' | 'postgresql', - webSockets?: Array, - onProgress?: ProgressCallback, -): Promise { - const formData = new FormData(); +/** + * Build a Void-app deploy manifest from the inferred bindings + per-feature + * inputs. Pure shape transformation: reads no files, has no I/O. Both the + * legacy multipart packager (`packageBuild`) and the direct-to-R2 bundler + * (`prepareVoidDeployBundle`) call into this so the manifest is computed + * once per deploy and the wire shape stays bit-for-bit identical between + * the two paths. + */ +export function buildVoidManifest(opts: { + bindings: InferredBindings; + migrations?: Array; + schedules?: Array; + ssr?: boolean; + framework?: string; + revalidate?: number | Record; + vars?: Record; + queues?: Array; + prerender?: Array; + assetConfig?: AssetConfig; + options?: PackageOptions; + hashedAssetsPrefix?: string; + headerRules?: Array; + redirectRules?: Array; + fallbackRules?: Array; + dialect?: 'sqlite' | 'postgresql'; + webSockets?: Array; + onProgress?: ProgressCallback; +}): DeployManifest { + const { + bindings, + migrations, + schedules, + ssr, + framework, + revalidate, + vars, + queues, + prerender, + assetConfig, + options, + hashedAssetsPrefix, + headerRules, + redirectRules, + fallbackRules, + dialect, + webSockets, + onProgress, + } = opts; - // Build manifest from inferred bindings const manifest: DeployManifest = { version: 3, assetManifestVersion: 2, bindings: {} }; if (bindings.needsAuth) { manifest.auth = true; @@ -444,18 +521,11 @@ export async function packageBuild( }); } - // Add migration metadata to manifest + SQL content as FormData entries if (migrations && migrations.length > 0) { manifest.migrations = { dialect: dialect ?? 'sqlite', pending: migrations.length, }; - onProgress?.( - `Packaging ${migrations.length} migration${migrations.length === 1 ? '' : 's'}...`, - ); - for (const m of migrations) { - formData.append(`migration:${m.name}`, new Blob([m.sql]), m.name); - } } if (schedules && schedules.length > 0) { @@ -503,13 +573,6 @@ export async function packageBuild( resolvedAssetConfig = { ...assetConfig, ...chain.assetConfig }; resolvedFallbackRules = chain.fallbackRules; } else if (fallbackRules && fallbackRules.length > 0) { - // Fallbacks ship for any app type, but the SPA-shell chain (appending - // `/* -> /index.html`) only runs when `not_found_handling` is - // `single-page-application`. On SSR / Void / framework apps, user - // fallbacks still fire post-lookup, but no SPA shell is synthesised — - // callers relying on the rules to provide an `/index.html` safety net - // will hit the platform's default 404 on unmatched paths. Warn so the - // expectation mismatch surfaces at build time. const msg = `routing.fallbacks is set but appType is not "spa"; fallbacks will be applied, ` + `but no "/* -> /index.html" SPA shell fallback is synthesised — unmatched paths fall through to the platform 404.`; @@ -556,46 +619,146 @@ export async function packageBuild( manifest.websocket = websocketManifest; } - formData.append('manifest', JSON.stringify(manifest)); + return manifest; +} - // Add worker files - onProgress?.('Packaging worker files...'); - const workerDir = join(distDir, workerDirName); - const workerFiles = collectFiles(workerDir); - for (const filePath of workerFiles) { +/** + * Read every worker module under `workerDir` once and return them as a map + * keyed by relative path. Skipped files (build metadata, wrangler config, + * non-module extensions) are filtered out by `isWorkerModule`. Reads each + * file from disk exactly once — buffer threading downstream is the caller's + * responsibility. + */ +export function collectWorkerFiles(workerDir: string): Map { + const out = new Map(); + let workerPaths: Array; + try { + workerPaths = collectFiles(workerDir); + } catch (err) { + if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') { + return out; + } + throw err; + } + for (const filePath of workerPaths) { const relativePath = toSlash(relative(workerDir, filePath)); if (!isWorkerModule(relativePath)) { continue; } - const content = readFileSync(filePath); - formData.append(`worker:${relativePath}`, new Blob([content]), relativePath); + out.set(relativePath, readFileSync(filePath)); } + return out; +} - // Add client assets - const clientDir = join(distDir, 'client'); - try { - const { isIgnored, assetsIgnoreFilePresent } = createAssetsIgnoreFunction(clientDir); - const clientFiles = collectFiles(clientDir); - let processed = 0; - for (const filePath of clientFiles) { - processed++; - await tickProgress(processed, clientFiles.length, onProgress, 'Packaging assets'); - const relativePath = toSlash(relative(clientDir, filePath)); - if (isIgnored('/' + relativePath)) { - continue; +export async function packageBuild( + distDir: string, + workerDirName: string, + bindings: InferredBindings, + migrations?: Array, + schedules?: Array, + ssr?: boolean, + framework?: string, + revalidate?: number | Record, + vars?: Record, + queues?: Array, + prerender?: Array, + assetConfig?: AssetConfig, + options?: PackageOptions, + neededAssets?: Set | null, + hashedAssetsPrefix?: string, + headerRules?: Array, + redirectRules?: Array, + fallbackRules?: Array, + dialect?: 'sqlite' | 'postgresql', + webSockets?: Array, + onProgress?: ProgressCallback, + /** + * Pre-collected asset bytes from `collectAndHashAssets`. When provided, + * this function uses these buffers directly instead of re-reading files + * from disk. Eliminates the chain double-read between hashing and + * packaging (Codex Finding #2 in design doc 0068 review). + * + * When omitted, falls back to reading the client directory directly — + * this preserves backward compatibility for existing test callers. + */ + assetFiles?: Map, +): Promise { + const formData = new FormData(); + + const manifest = buildVoidManifest({ + bindings, + migrations, + schedules, + ssr, + framework, + revalidate, + vars, + queues, + prerender, + assetConfig, + options, + hashedAssetsPrefix, + headerRules, + redirectRules, + fallbackRules, + dialect, + webSockets, + onProgress, + }); + + if (migrations && migrations.length > 0) { + onProgress?.( + `Packaging ${migrations.length} migration${migrations.length === 1 ? '' : 's'}...`, + ); + for (const m of migrations) { + formData.append(`migration:${m.name}`, new Blob([m.sql]), m.name); + } + } + + formData.append('manifest', JSON.stringify(manifest)); + + // Worker files (read once via the shared helper). + onProgress?.('Packaging worker files...'); + const workerDir = join(distDir, workerDirName); + for (const [relativePath, content] of collectWorkerFiles(workerDir)) { + formData.append(`worker:${relativePath}`, new Blob([new Uint8Array(content)]), relativePath); + } + + // Client assets — prefer the pre-collected buffer cache from + // `collectAndHashAssets`. Falls back to disk reads only when no cache + // was supplied (preserves legacy callers / tests). + if (assetFiles) { + appendAssetsFromCache(formData, assetFiles, neededAssets ?? null); + } else { + const clientDir = join(distDir, 'client'); + try { + const { isIgnored, assetsIgnoreFilePresent } = createAssetsIgnoreFunction(clientDir); + const clientFiles = collectFiles(clientDir); + let processed = 0; + for (const filePath of clientFiles) { + processed++; + await tickProgress(processed, clientFiles.length, onProgress, 'Packaging assets'); + const relativePath = toSlash(relative(clientDir, filePath)); + if (isIgnored('/' + relativePath)) { + continue; + } + checkWorkerJsAsset(relativePath, assetsIgnoreFilePresent); + if (neededAssets && !neededAssets.has('/' + relativePath)) { + continue; + } + const content = readFileSync(filePath); + formData.append(`asset:${relativePath}`, new Blob([content]), relativePath); } - checkWorkerJsAsset(relativePath, assetsIgnoreFilePresent); - if (neededAssets && !neededAssets.has('/' + relativePath)) { - continue; + } catch (err) { + if ( + err instanceof Error && + 'code' in err && + (err as NodeJS.ErrnoException).code === 'ENOENT' + ) { + // No client directory — API-only project + } else { + throw err; } - const content = readFileSync(filePath); - formData.append(`asset:${relativePath}`, new Blob([content]), relativePath); - } - } catch (err) { - if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') { - // No client directory — API-only project - } else { - throw err; } } @@ -607,20 +770,42 @@ export async function packageBuild( } /** - * Package a static build (no worker) into a FormData ready for upload. + * Append asset entries to FormData from the in-memory buffer cache. Mirrors + * the legacy disk-read loop but consumes pre-hashed bytes — the same buffers + * `collectAndHashAssets` computed BLAKE3+MD5 over — so the deploy chain + * reads each asset file from disk exactly once end-to-end. + * + * `_worker.js` safety guards already ran in `collectAndHashAssets` (which + * walks the same directory with the same ignore filter), so callers that + * feed buffers from there don't need to re-check here. */ -export async function packageStaticBuild( - outputDir: string, - appType: 'spa' | 'static', - neededAssets?: Set | null, - hashedAssetsPrefix?: string, - headerRules?: Array, - redirectRules?: Array, - fallbackRules?: Array, - onProgress?: ProgressCallback, -): Promise { - const formData = new FormData(); +function appendAssetsFromCache( + formData: FormData, + assetFiles: Map, + neededAssets: Set | null, +): void { + for (const [absPath, content] of assetFiles) { + if (neededAssets && !neededAssets.has(absPath)) { + continue; + } + const key = absPath.startsWith('/') ? absPath.slice(1) : absPath; + formData.append(`asset:${key}`, new Blob([new Uint8Array(content)]), key); + } +} +/** + * Build a static-deploy manifest. Pure shape transformation — no I/O. + * Shared between the legacy multipart packager (`packageStaticBuild`) and + * the direct-to-R2 bundler (`prepareStaticDeployBundle`). + */ +export function buildStaticManifest(opts: { + appType: 'spa' | 'static'; + hashedAssetsPrefix?: string; + headerRules?: Array; + redirectRules?: Array; + fallbackRules?: Array; +}): DeployManifest { + const { appType, hashedAssetsPrefix, headerRules, redirectRules, fallbackRules } = opts; const { assetConfig, fallbackRules: resolvedFallbackRules } = resolveSpaFallbackChain( appType, fallbackRules, @@ -650,24 +835,60 @@ export async function packageStaticBuild( manifest.fallbackRules = stripDevOnlyRuleFields(resolvedFallbackRules); } + return manifest; +} + +/** + * Package a static build (no worker) into a FormData ready for upload. + */ +export async function packageStaticBuild( + outputDir: string, + appType: 'spa' | 'static', + neededAssets?: Set | null, + hashedAssetsPrefix?: string, + headerRules?: Array, + redirectRules?: Array, + fallbackRules?: Array, + onProgress?: ProgressCallback, + /** + * Pre-collected asset bytes from `collectAndHashAssets`. When provided, + * skips the directory walk and re-reads — eliminates the chain + * double-read flagged in design doc 0068 review. + */ + assetFiles?: Map, +): Promise { + const formData = new FormData(); + + const manifest = buildStaticManifest({ + appType, + hashedAssetsPrefix, + headerRules, + redirectRules, + fallbackRules, + }); + formData.append('manifest', JSON.stringify(manifest)); - const { isIgnored, assetsIgnoreFilePresent } = createAssetsIgnoreFunction(outputDir); - const files = collectFiles(outputDir); - let processed = 0; - for (const filePath of files) { - processed++; - await tickProgress(processed, files.length, onProgress, 'Packaging assets'); - const relativePath = toSlash(relative(outputDir, filePath)); - if (isIgnored('/' + relativePath)) { - continue; - } - checkWorkerJsAsset(relativePath, assetsIgnoreFilePresent); - if (neededAssets && !neededAssets.has('/' + relativePath)) { - continue; + if (assetFiles) { + appendAssetsFromCache(formData, assetFiles, neededAssets ?? null); + } else { + const { isIgnored, assetsIgnoreFilePresent } = createAssetsIgnoreFunction(outputDir); + const files = collectFiles(outputDir); + let processed = 0; + for (const filePath of files) { + processed++; + await tickProgress(processed, files.length, onProgress, 'Packaging assets'); + const relativePath = toSlash(relative(outputDir, filePath)); + if (isIgnored('/' + relativePath)) { + continue; + } + checkWorkerJsAsset(relativePath, assetsIgnoreFilePresent); + if (neededAssets && !neededAssets.has('/' + relativePath)) { + continue; + } + const content = readFileSync(filePath); + formData.append(`asset:${relativePath}`, new Blob([content]), relativePath); } - const content = readFileSync(filePath); - formData.append(`asset:${relativePath}`, new Blob([content]), relativePath); } if (neededAssets) { @@ -685,11 +906,14 @@ export async function packageStaticBuild( * inside the assets directory (SvelteKit, Astro). Worker files are excluded * from asset collection when directories overlap. */ -export async function packageFrameworkBuild(opts: { +/** + * Build a framework-deploy manifest. Pure shape transformation — no I/O. + * Shared between the legacy multipart packager (`packageFrameworkBuild`) + * and the direct-to-R2 bundler (`prepareFrameworkDeployBundle`). + */ +export function buildFrameworkManifest(opts: { frameworkName: string; - workerDir: string; workerMain: string; - assetsDir: string; bindings: InferredBindings; migrations?: Array; schedules?: Array; @@ -700,20 +924,15 @@ export async function packageFrameworkBuild(opts: { assetConfig?: AssetConfig; vars?: Record; packageOptions?: PackageOptions; - neededAssets?: Set | null; hashedAssetsPrefix?: string; headerRules?: Array; redirectRules?: Array; fallbackRules?: Array; dialect?: 'sqlite' | 'postgresql'; webSockets?: Array; - assetIgnorePatterns?: Array; - onProgress?: ProgressCallback; -}): Promise { - const formData = new FormData(); +}): DeployManifest { const po = opts.packageOptions; - // Build manifest const manifest: DeployManifest = { version: 3, assetManifestVersion: 2, bindings: {} }; const hasMigrations = opts.migrations && opts.migrations.length > 0; if (opts.bindings.needsD1 || opts.bindings.needsAuth || hasMigrations) { @@ -738,12 +957,6 @@ export async function packageFrameworkBuild(opts: { dialect: opts.dialect ?? 'sqlite', pending: opts.migrations.length, }; - opts.onProgress?.( - `Packaging ${opts.migrations.length} migration${opts.migrations.length === 1 ? '' : 's'}...`, - ); - for (const m of opts.migrations) { - formData.append(`migration:${m.name}`, new Blob([m.sql]), m.name); - } } if (opts.schedules && opts.schedules.length > 0) { @@ -800,57 +1013,105 @@ export async function packageFrameworkBuild(opts: { manifest.websocket = websocketManifest; } + return manifest; +} + +export async function packageFrameworkBuild(opts: { + frameworkName: string; + workerDir: string; + workerMain: string; + assetsDir: string; + bindings: InferredBindings; + migrations?: Array; + schedules?: Array; + queues?: Array; + revalidate?: number | Record; + revalidateQueryAllowlist?: Record>; + prerender?: Array; + assetConfig?: AssetConfig; + vars?: Record; + packageOptions?: PackageOptions; + neededAssets?: Set | null; + hashedAssetsPrefix?: string; + headerRules?: Array; + redirectRules?: Array; + fallbackRules?: Array; + dialect?: 'sqlite' | 'postgresql'; + webSockets?: Array; + assetIgnorePatterns?: Array; + onProgress?: ProgressCallback; + /** + * Pre-collected asset bytes from `collectAndHashAssets`. When provided, + * skips the assets directory walk and re-reads — eliminates the chain + * double-read flagged in design doc 0068 review. + */ + assetFiles?: Map; +}): Promise { + const formData = new FormData(); + + const manifest = buildFrameworkManifest(opts); + + if (opts.migrations && opts.migrations.length > 0) { + opts.onProgress?.( + `Packaging ${opts.migrations.length} migration${opts.migrations.length === 1 ? '' : 's'}...`, + ); + for (const m of opts.migrations) { + formData.append(`migration:${m.name}`, new Blob([m.sql]), m.name); + } + } + formData.append('manifest', JSON.stringify(manifest)); + // Worker files (read once via the shared helper). + opts.onProgress?.('Packaging worker files...'); + for (const [relativePath, content] of collectWorkerFiles(opts.workerDir)) { + formData.append(`worker:${relativePath}`, new Blob([new Uint8Array(content)]), relativePath); + } + // Collect worker files and asset files, handling directory overlap const workerDirNorm = opts.workerDir.replace(/\\/g, '/'); const assetsDirNorm = opts.assetsDir.replace(/\\/g, '/'); const isOverlap = workerDirNorm.startsWith(assetsDirNorm + '/') || workerDirNorm === assetsDirNorm; - // Worker files - opts.onProgress?.('Packaging worker files...'); - const workerFiles = collectFiles(opts.workerDir); - for (const filePath of workerFiles) { - const relativePath = toSlash(relative(opts.workerDir, filePath)); - if (!isWorkerModule(relativePath)) { - continue; - } - const content = readFileSync(filePath); - formData.append(`worker:${relativePath}`, new Blob([content]), relativePath); - } - - // Asset files - try { - const { isIgnored, assetsIgnoreFilePresent } = createAssetsIgnoreFunction( - opts.assetsDir, - opts.assetIgnorePatterns, - ); - const assetFiles = collectFiles(opts.assetsDir); - let processed = 0; - for (const filePath of assetFiles) { - processed++; - await tickProgress(processed, assetFiles.length, opts.onProgress, 'Packaging assets'); - // Skip worker files when workerDir overlaps with assetsDir - if (isOverlap && filePath.replace(/\\/g, '/').startsWith(workerDirNorm)) { - continue; + if (opts.assetFiles) { + appendAssetsFromCache(formData, opts.assetFiles, opts.neededAssets ?? null); + } else { + try { + const { isIgnored, assetsIgnoreFilePresent } = createAssetsIgnoreFunction( + opts.assetsDir, + opts.assetIgnorePatterns, + ); + const diskFiles = collectFiles(opts.assetsDir); + let processed = 0; + for (const filePath of diskFiles) { + processed++; + await tickProgress(processed, diskFiles.length, opts.onProgress, 'Packaging assets'); + // Skip worker files when workerDir overlaps with assetsDir + if (isOverlap && filePath.replace(/\\/g, '/').startsWith(workerDirNorm)) { + continue; + } + const relativePath = toSlash(relative(opts.assetsDir, filePath)); + if (isIgnored('/' + relativePath)) { + continue; + } + checkWorkerJsAsset(relativePath, assetsIgnoreFilePresent); + if (opts.neededAssets && !opts.neededAssets.has('/' + relativePath)) { + continue; + } + const content = readFileSync(filePath); + formData.append(`asset:${relativePath}`, new Blob([content]), relativePath); } - const relativePath = toSlash(relative(opts.assetsDir, filePath)); - if (isIgnored('/' + relativePath)) { - continue; - } - checkWorkerJsAsset(relativePath, assetsIgnoreFilePresent); - if (opts.neededAssets && !opts.neededAssets.has('/' + relativePath)) { - continue; + } catch (err) { + if ( + err instanceof Error && + 'code' in err && + (err as NodeJS.ErrnoException).code === 'ENOENT' + ) { + // No assets directory + } else { + throw err; } - const content = readFileSync(filePath); - formData.append(`asset:${relativePath}`, new Blob([content]), relativePath); - } - } catch (err) { - if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') { - // No assets directory - } else { - throw err; } } diff --git a/packages/void/test/integration/direct-r2-deploy.test.ts b/packages/void/test/integration/direct-r2-deploy.test.ts new file mode 100644 index 000000000..b0bbcf3a1 --- /dev/null +++ b/packages/void/test/integration/direct-r2-deploy.test.ts @@ -0,0 +1,561 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Capture how many times each asset path is read via `readFileSync`. Mocked +// before any importer of `node:fs` so the spy is in place when `package.ts` +// resolves its `import { readFileSync } from 'node:fs'` binding. Only assets +// inside the test fixture directory are counted; other reads (e.g. +// .assetsignore lookup misses) pass through to the real fs. +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + readFileSync: vi.fn((...args: Parameters) => + actual.readFileSync(...args), + ), + }; +}); + +// `@clack/prompts` opens TTY-bound spinners we don't want to render in tests. +const noopSpinner = { + start: vi.fn(), + stop: vi.fn(), + message: vi.fn(), +}; +vi.mock('@clack/prompts', async () => { + const actual = await vi.importActual('@clack/prompts'); + return { + ...actual, + intro: vi.fn(), + outro: vi.fn(), + note: vi.fn(), + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + step: vi.fn(), + success: vi.fn(), + }, + spinner: () => noopSpinner, + }; +}); + +// `output.ts` wraps clack's spinner — re-export the same noop. Used by +// runStaticDeploy through `createSpinner()`. +vi.mock('../../src/cli/output.ts', async () => { + const actual = + await vi.importActual('../../src/cli/output.ts'); + return { + ...actual, + createSpinner: () => noopSpinner, + }; +}); + +const fsModule = await import('node:fs'); +const readFileSyncMock = fsModule.readFileSync as unknown as ReturnType; + +const { runStaticDeploy } = await import('../../src/cli/deploy.ts'); +const { openDeployLog } = await import('../../src/cli/log-file.ts'); +const { PlatformClient } = await import('../../src/cli/client.ts'); + +const fetchMock = vi.fn(); +let originalFetch: typeof globalThis.fetch | undefined; + +let workdir: string; +let assetsDir: string; +const fixtureFiles: Record = { + '/index.html': 'hi\n', + '/style.css': 'body { color: red; }\n', + '/script.js': 'console.log("hello");\n', + // Larger asset (>100KB) to stress buffer threading. + '/large.bin': Buffer.alloc(120_000, 0xa5), +}; + +beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), 'void-direct-r2-')); + assetsDir = join(workdir, 'dist'); + mkdirSync(assetsDir, { recursive: true }); + for (const [relPath, content] of Object.entries(fixtureFiles)) { + writeFileSync(join(assetsDir, relPath.slice(1)), content); + } + + originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + fetchMock.mockReset(); + readFileSyncMock.mockClear(); +}); + +afterEach(() => { + rmSync(workdir, { recursive: true, force: true }); + if (originalFetch) { + globalThis.fetch = originalFetch; + } +}); + +interface MockState { + preflightCalls: Array; + uploadUrlsCalls: Array; + r2PutCalls: Array<{ url: string; init: RequestInit }>; + finalizeCalls: Array<{ url: string; init: RequestInit }>; + multipartDeployCalls: Array<{ url: string; init: RequestInit }>; +} + +function installPlatformMocks(opts?: { + preflightSkipped?: number; + preflightNeeded?: Array; + r2PutBehavior?: (url: string) => Response | Promise; + finalizeEvents?: Array; +}): MockState { + const state: MockState = { + preflightCalls: [], + uploadUrlsCalls: [], + r2PutCalls: [], + finalizeCalls: [], + multipartDeployCalls: [], + }; + + fetchMock.mockImplementation(async (input: RequestInfo, init?: RequestInit) => { + const url = typeof input === 'string' ? input : (input as Request).url; + const reqInit = init ?? {}; + const method = reqInit.method ?? 'GET'; + + if (url.endsWith('/deploy/preflight') && method === 'POST') { + state.preflightCalls.push(JSON.parse(reqInit.body as string)); + return new Response( + JSON.stringify({ + needed: opts?.preflightNeeded ?? Object.keys(fixtureFiles), + total: Object.keys(fixtureFiles).length, + skipped: opts?.preflightSkipped ?? 0, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + + if (url.endsWith('/deploy/upload-urls') && method === 'POST') { + const body = JSON.parse(reqInit.body as string) as { + assets: Array<{ blake3: string; md5: string; contentType: string }>; + }; + state.uploadUrlsCalls.push(body); + const urls: Record }> = {}; + for (const asset of body.assets) { + urls[asset.blake3] = { + url: `https://r2-test/${asset.blake3}`, + headers: { + 'Content-Md5': hexToBase64(asset.md5), + 'Content-Type': asset.contentType, + }, + }; + } + return new Response(JSON.stringify({ urls }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + + if (url.startsWith('https://r2-test/') && method === 'PUT') { + state.r2PutCalls.push({ url, init: reqInit }); + if (opts?.r2PutBehavior) { + return opts.r2PutBehavior(url); + } + return new Response(null, { status: 200 }); + } + + if (url.endsWith('/deploy') && method === 'POST') { + const ct = (reqInit.headers as Record | undefined)?.['Content-Type']; + if (ct === 'application/json') { + state.finalizeCalls.push({ url, init: reqInit }); + return ndjsonResponse(opts?.finalizeEvents ?? defaultDoneEvents()); + } + // Multipart path (legacy). + state.multipartDeployCalls.push({ url, init: reqInit }); + return ndjsonResponse(defaultDoneEvents()); + } + + throw new Error(`unexpected fetch: ${method} ${url}`); + }); + + return state; +} + +function defaultDoneEvents(): Array { + return [ + { event: 'start', deploymentId: 'dep_test' }, + { event: 'asset_upload', total: 4, new: 4, cached: 0 }, + { + event: 'done', + deployment: 'dep_test', + url: 'https://test.void.app', + urls: ['https://test.void.app'], + assets: 4, + workers: 0, + }, + ]; +} + +function ndjsonResponse(events: Array): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const e of events) { + controller.enqueue(encoder.encode(`${JSON.stringify(e)}\n`)); + } + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { 'content-type': 'application/x-ndjson' }, + }); +} + +function hexToBase64(hex: string): string { + const buf = Buffer.from(hex, 'hex'); + return buf.toString('base64'); +} + +function makeClient(): InstanceType { + return new PlatformClient('test-token', { apiUrl: 'https://api.test', cliLog: openTestLog() }); +} + +let capturedLogPath: string | null = null; +function openTestLog(): ReturnType { + const log = openDeployLog(); + capturedLogPath = log.path; + return log; +} + +describe('direct-to-R2 deploy flow', () => { + it('uploads assets directly to R2 then calls finalize JSON endpoint', async () => { + const state = installPlatformMocks(); + const client = makeClient(); + + await runStaticDeploy( + workdir, + { projectId: 'proj_t', slug: 'test' }, + client, + { buildCommand: null, outputDir: assetsDir, appType: 'spa' }, + true, // skipBuild + undefined, // gitMetadata + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, // cliLog (tested separately) + false, // legacyUpload = false → direct-to-R2 + ); + + // Preflight + upload-urls + R2 PUTs + finalize were called once each. + expect(state.preflightCalls).toHaveLength(1); + expect(state.uploadUrlsCalls).toHaveLength(1); + expect(state.r2PutCalls).toHaveLength(4); + expect(state.finalizeCalls).toHaveLength(1); + expect(state.multipartDeployCalls).toHaveLength(0); + + // Each R2 PUT carries Content-Md5 and the asset bytes. + for (const { init } of state.r2PutCalls) { + const headers = init.headers as Record; + expect(headers['Content-Md5']).toBeDefined(); + expect(headers['Content-Type']).toBeDefined(); + expect(init.method).toBe('PUT'); + expect(init.body).toBeInstanceOf(Uint8Array); + } + + // Body payload of finalize is JSON, not multipart. + const finalize = state.finalizeCalls[0]; + expect((finalize.init.headers as Record)['Content-Type']).toBe( + 'application/json', + ); + const finalizeBody = JSON.parse(finalize.init.body as string); + expect(finalizeBody.manifest.assetManifestVersion).toBe(2); + expect(finalizeBody.assetManifest['/index.html']).toMatchObject({ + blake3: expect.any(String), + md5: expect.any(String), + size: fixtureFiles['/index.html'].length, + }); + expect(finalizeBody.preflight).toBe(null); // skipped=0 means no preflight reuse + expect(finalizeBody.workerFiles).toBeUndefined(); // static → no worker bytes + }); + + it('reads each asset file from disk EXACTLY ONCE end-to-end (Codex Finding #2)', async () => { + installPlatformMocks(); + const client = makeClient(); + + // Reset the mock so we count ONLY reads from this run. + readFileSyncMock.mockClear(); + + await runStaticDeploy( + workdir, + { projectId: 'proj_t', slug: 'test' }, + client, + { buildCommand: null, outputDir: assetsDir, appType: 'spa' }, + true, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + false, + ); + + // Filter to just the fixture files — `.assetsignore` lookup may fire + // an additional read against a file that doesn't exist (caught and + // swallowed by package.ts via existsSync), but that's not an asset + // re-read so we filter it out. + const reads = readFileSyncMock.mock.calls.filter(([p]: Array) => { + const path = String(p); + return ( + path.endsWith('index.html') || + path.endsWith('style.css') || + path.endsWith('script.js') || + path.endsWith('large.bin') + ); + }); + + // 4 fixture assets × 1 read each = 4 reads total. The new direct-to-R2 + // flow uses the in-memory `assetFiles` cache from collectAndHashAssets + // for the upload phase — no second pass over the disk. + expect(reads).toHaveLength(4); + const uniquePaths = new Set(reads.map(([p]: Array) => String(p))); + expect(uniquePaths.size).toBe(4); + }); + + it('skips upload-urls + R2 PUT phase when no assets are needed', async () => { + const state = installPlatformMocks({ preflightNeeded: [], preflightSkipped: 4 }); + const client = makeClient(); + + await runStaticDeploy( + workdir, + { projectId: 'proj_t', slug: 'test' }, + client, + { buildCommand: null, outputDir: assetsDir, appType: 'spa' }, + true, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + false, + ); + + expect(state.uploadUrlsCalls).toHaveLength(0); + expect(state.r2PutCalls).toHaveLength(0); + expect(state.finalizeCalls).toHaveLength(1); + const body = JSON.parse(state.finalizeCalls[0].init.body as string); + expect(body.preflight).toBe('true'); + }); + + it('retries 5xx R2 PUT failures up to 3 times', async () => { + let attemptsForFirstAsset = 0; + const state = installPlatformMocks({ + r2PutBehavior(url) { + // Pick a deterministic asset and fail it 2 times. + if (url.includes('/') && attemptsForFirstAsset < 2) { + // Match only the very first PUT URL (preserve mock state) + attemptsForFirstAsset++; + if (attemptsForFirstAsset <= 2) { + return new Response('temporary', { status: 503 }); + } + } + return new Response(null, { status: 200 }); + }, + }); + const client = makeClient(); + + await runStaticDeploy( + workdir, + { projectId: 'proj_t', slug: 'test' }, + client, + { buildCommand: null, outputDir: assetsDir, appType: 'spa' }, + true, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + false, + ); + + // 4 assets but the first one was retried 2 times, so total PUTs = 4 + 2 = 6. + expect(state.r2PutCalls.length).toBeGreaterThanOrEqual(5); + expect(state.finalizeCalls).toHaveLength(1); + }); + + it('surfaces 4xx R2 PUT errors with asset path and hash', async () => { + installPlatformMocks({ + r2PutBehavior() { + return new Response('SignatureMismatch', { status: 403 }); + }, + }); + const client = makeClient(); + + await expect( + runStaticDeploy( + workdir, + { projectId: 'proj_t', slug: 'test' }, + client, + { buildCommand: null, outputDir: assetsDir, appType: 'spa' }, + true, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + false, + ), + ).rejects.toThrow(/R2 PUT failed.+SignatureMismatch/); + }); + + it('uses legacy multipart path when legacyUpload=true', async () => { + const state = installPlatformMocks(); + const client = makeClient(); + + await runStaticDeploy( + workdir, + { projectId: 'proj_t', slug: 'test' }, + client, + { buildCommand: null, outputDir: assetsDir, appType: 'spa' }, + true, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + true, // legacyUpload = true + ); + + // Direct path NOT used. + expect(state.uploadUrlsCalls).toHaveLength(0); + expect(state.r2PutCalls).toHaveLength(0); + expect(state.finalizeCalls).toHaveLength(0); + + // Multipart was used. + expect(state.multipartDeployCalls).toHaveLength(1); + const headers = state.multipartDeployCalls[0].init.headers as Record; + // The fetch body is FormData → CT not set explicitly, fetch handles it. + // Just verify the multipart path was hit. + expect(headers['Content-Type']).toBeUndefined(); + expect(state.multipartDeployCalls[0].init.body).toBeInstanceOf(FormData); + }); + + it('reads each asset file from disk EXACTLY ONCE in legacy multipart path too', async () => { + installPlatformMocks(); + const client = makeClient(); + readFileSyncMock.mockClear(); + + await runStaticDeploy( + workdir, + { projectId: 'proj_t', slug: 'test' }, + client, + { buildCommand: null, outputDir: assetsDir, appType: 'spa' }, + true, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + true, + ); + + const reads = readFileSyncMock.mock.calls.filter(([p]: Array) => { + const path = String(p); + return ( + path.endsWith('index.html') || + path.endsWith('style.css') || + path.endsWith('script.js') || + path.endsWith('large.bin') + ); + }); + + // The legacy path also threads buffers through via `assetFiles` so the + // double-read is eliminated end-to-end. + expect(reads).toHaveLength(4); + }); + + it('writes presign + r2_put + finalize events to the JSONL log', async () => { + installPlatformMocks(); + const cliLog = openDeployLog(); + capturedLogPath = cliLog.path; + expect(capturedLogPath).toBeTruthy(); + + const client = new PlatformClient('test-token', { apiUrl: 'https://api.test', cliLog }); + + await runStaticDeploy( + workdir, + { projectId: 'proj_t', slug: 'test' }, + client, + { buildCommand: null, outputDir: assetsDir, appType: 'spa' }, + true, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + cliLog, + false, + ); + + await cliLog.close(); + const lines = readFileSync(capturedLogPath as string, 'utf-8') + .trim() + .split('\n') + .map((l) => JSON.parse(l) as { msg: string }); + const events = lines.map((l) => l.msg); + expect(events).toContain('presign_request_start'); + expect(events).toContain('presign_request_end'); + expect(events).toContain('r2_put_start'); + expect(events).toContain('r2_put_end'); + expect(events).toContain('finalize_start'); + expect(events).toContain('finalize_end'); + }); + + it('failure-mode error message ends with the JSONL log path', async () => { + installPlatformMocks({ + finalizeEvents: [{ event: 'error', message: 'something broke', step: 'finalize' }], + }); + const cliLog = openDeployLog(); + const logPath = cliLog.path; + expect(logPath).toBeTruthy(); + + const client = new PlatformClient('test-token', { apiUrl: 'https://api.test', cliLog }); + + await expect( + runStaticDeploy( + workdir, + { projectId: 'proj_t', slug: 'test' }, + client, + { buildCommand: null, outputDir: assetsDir, appType: 'spa' }, + true, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + cliLog, + false, + ), + ).rejects.toThrow(new RegExp(escapeRegex(logPath as string))); + + await cliLog.close(); + }); +}); + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/packages/void/test/unit/direct-r2-client.test.ts b/packages/void/test/unit/direct-r2-client.test.ts new file mode 100644 index 000000000..e1976ad1b --- /dev/null +++ b/packages/void/test/unit/direct-r2-client.test.ts @@ -0,0 +1,251 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PlatformClient, type ManifestOnlyDeployPayload } from '../../src/cli/client.ts'; +import type { DeployManifest } from '../../src/cli/types.ts'; + +const fetchMock = vi.fn(); +let originalFetch: typeof globalThis.fetch | undefined; + +beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + fetchMock.mockReset(); +}); + +afterEach(() => { + if (originalFetch) { + globalThis.fetch = originalFetch; + } +}); + +const apiUrl = 'https://api.test'; +const projectId = 'proj_test'; +const token = 'auth-token-x'; + +function makeClient(): PlatformClient { + return new PlatformClient(token, { apiUrl }); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function ndjsonStreamResponse(events: Array, status = 200): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)); + } + controller.close(); + }, + }); + return new Response(stream, { status, headers: { 'content-type': 'application/x-ndjson' } }); +} + +describe('PlatformClient.requestUploadUrls', () => { + it('POSTs JSON to /:id/deploy/upload-urls and returns the urls map', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + urls: { + aabbccdd: { url: 'https://r2/.../1', headers: { 'Content-Md5': 'abc==' } }, + }, + }), + ); + + const client = makeClient(); + const urls = await client.requestUploadUrls(projectId, [ + { blake3: 'aabbccdd', md5: '11223344', size: 17, contentType: 'text/plain' }, + ]); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${apiUrl}/projects/${projectId}/deploy/upload-urls`); + expect(init.method).toBe('POST'); + expect((init.headers as Record)['Content-Type']).toBe('application/json'); + expect((init.headers as Record).Authorization).toBe(`Bearer ${token}`); + + const body = JSON.parse(init.body as string); + expect(body).toEqual({ + assets: [{ blake3: 'aabbccdd', md5: '11223344', size: 17, contentType: 'text/plain' }], + }); + + expect(urls).toEqual({ + aabbccdd: { url: 'https://r2/.../1', headers: { 'Content-Md5': 'abc==' } }, + }); + }); + + it('throws PlatformApiError on non-2xx', async () => { + fetchMock.mockResolvedValueOnce(new Response('forbidden', { status: 403 })); + const client = makeClient(); + await expect( + client.requestUploadUrls(projectId, [ + { blake3: 'a'.repeat(32), md5: 'b'.repeat(32), size: 1, contentType: 'text/plain' }, + ]), + ).rejects.toMatchObject({ + name: 'PlatformApiError', + status: 403, + }); + }); +}); + +describe('PlatformClient.finalizeDeploy', () => { + const baseManifest: DeployManifest = { + version: 3, + assetManifestVersion: 2, + bindings: {}, + type: 'spa', + assetConfig: { not_found_handling: 'single-page-application' }, + }; + + function makePayload(): ManifestOnlyDeployPayload { + return { + manifest: baseManifest, + assetManifest: { + '/index.html': { + blake3: 'a'.repeat(32), + md5: 'b'.repeat(32), + size: 12, + hash: 'a'.repeat(32), + }, + }, + source: 'manual', + commit: null, + repositoryUrl: null, + commitUrl: null, + gitRef: null, + preflight: 'true', + }; + } + + it('POSTs JSON (NOT multipart) to /:id/deploy and parses the NDJSON event stream', async () => { + fetchMock.mockResolvedValueOnce( + ndjsonStreamResponse([ + { event: 'start', deploymentId: 'dep_x' }, + { event: 'asset_upload', total: 1, new: 0, cached: 1 }, + { + event: 'done', + deployment: 'dep_x', + url: 'https://x.void.app', + urls: [], + assets: 1, + workers: 0, + }, + ]), + ); + + const client = makeClient(); + const events = []; + for await (const event of client.finalizeDeploy(projectId, makePayload())) { + events.push(event); + } + + // Verify wire shape: JSON, not multipart. + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${apiUrl}/projects/${projectId}/deploy`); + expect(init.method).toBe('POST'); + const headers = init.headers as Record; + expect(headers['Content-Type']).toBe('application/json'); + expect(headers.Authorization).toBe(`Bearer ${token}`); + expect(typeof init.body).toBe('string'); + const parsed = JSON.parse(init.body as string); + expect(parsed.manifest).toEqual(baseManifest); + expect(parsed.assetManifest['/index.html']).toMatchObject({ + blake3: 'a'.repeat(32), + md5: 'b'.repeat(32), + size: 12, + }); + expect(parsed.preflight).toBe('true'); + + // Verify all events were yielded in order, parsed from NDJSON. + expect(events).toEqual([ + { event: 'start', deploymentId: 'dep_x' }, + { event: 'asset_upload', total: 1, new: 0, cached: 1 }, + { + event: 'done', + deployment: 'dep_x', + url: 'https://x.void.app', + urls: [], + assets: 1, + workers: 0, + }, + ]); + }); + + it('handles NDJSON split across chunk boundaries', async () => { + // Hand-craft a stream that splits the second event mid-way. + const lines = [ + `${JSON.stringify({ event: 'start', deploymentId: 'd1' })}\n`, + `${JSON.stringify({ event: 'heartbeat', step: 'preflight' })}`, + `\n${JSON.stringify({ event: 'done', deployment: 'd1', url: 'https://x', urls: [], assets: 0, workers: 0 })}\n`, + ]; + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(lines[0] + lines[1])); + controller.enqueue(encoder.encode(lines[2])); + controller.close(); + }, + }); + fetchMock.mockResolvedValueOnce( + new Response(stream, { status: 200, headers: { 'content-type': 'application/x-ndjson' } }), + ); + + const client = makeClient(); + const events = []; + for await (const event of client.finalizeDeploy(projectId, makePayload())) { + events.push(event); + } + + expect(events.map((e) => (e as { event?: string }).event)).toEqual([ + 'start', + 'heartbeat', + 'done', + ]); + }); + + it('throws PlatformApiError on non-2xx', async () => { + fetchMock.mockResolvedValueOnce(new Response('busted', { status: 500 })); + const client = makeClient(); + const gen = client.finalizeDeploy(projectId, makePayload()); + await expect(gen.next()).rejects.toMatchObject({ + name: 'PlatformApiError', + status: 500, + }); + }); + + it('serializes workerFiles as base64 in the JSON body', async () => { + fetchMock.mockResolvedValueOnce( + ndjsonStreamResponse([ + { + event: 'done', + deployment: 'd1', + url: 'https://x', + urls: [], + assets: 0, + workers: 1, + }, + ]), + ); + + const client = makeClient(); + const payload = makePayload(); + payload.workerFiles = { + 'index.mjs': Buffer.from('export default {};').toString('base64'), + }; + + for await (const _event of client.finalizeDeploy(projectId, payload)) { + // no-op — we only need the request body + } + + const init = fetchMock.mock.calls[0][1] as RequestInit; + const parsed = JSON.parse(init.body as string); + expect(parsed.workerFiles).toEqual({ + 'index.mjs': Buffer.from('export default {};').toString('base64'), + }); + }); +}); diff --git a/packages/void/test/unit/direct-r2-upload.test.ts b/packages/void/test/unit/direct-r2-upload.test.ts new file mode 100644 index 000000000..aa11b2cd0 --- /dev/null +++ b/packages/void/test/unit/direct-r2-upload.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + DirectR2PutError, + uploadAssetsToR2, + type DirectR2PutItem, +} from '../../src/cli/direct-r2-upload.ts'; + +function makeItem( + path: string, + hash: string, + body = Buffer.from(`bytes-${path}`), +): DirectR2PutItem { + return { + path, + hash, + body, + url: `https://r2/${hash}`, + headers: { 'Content-Md5': 'aGVsbG8=', 'Content-Type': 'text/plain' }, + }; +} + +const cliLog = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + path: '/tmp/test.jsonl', + close: vi.fn(), +} as const; + +beforeEach(() => { + cliLog.info.mockReset(); + cliLog.warn.mockReset(); + cliLog.error.mockReset(); + cliLog.debug.mockReset(); +}); + +describe('uploadAssetsToR2', () => { + it('PUTs each item once when all succeed', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + const items = [makeItem('/a.html', 'h1'), makeItem('/b.css', 'h2'), makeItem('/c.js', 'h3')]; + + await uploadAssetsToR2(items, cliLog, fetchMock); + + expect(fetchMock).toHaveBeenCalledTimes(3); + const urls = new Set(fetchMock.mock.calls.map(([u]) => u)); + expect(urls).toEqual(new Set(['https://r2/h1', 'https://r2/h2', 'https://r2/h3'])); + + // Per-asset start/end events. + const startEvents = cliLog.info.mock.calls.filter(([msg]) => msg === 'r2_put_start'); + const endEvents = cliLog.info.mock.calls.filter(([msg]) => msg === 'r2_put_end'); + expect(startEvents).toHaveLength(3); + expect(endEvents).toHaveLength(3); + }); + + it('passes presigned headers verbatim and uses PUT method', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + + await uploadAssetsToR2([makeItem('/a.html', 'h1')], cliLog, fetchMock); + + const [, init] = fetchMock.mock.calls[0]; + expect(init.method).toBe('PUT'); + expect(init.headers['Content-Md5']).toBe('aGVsbG8='); + expect(init.headers['Content-Type']).toBe('text/plain'); + expect(init.body).toBeInstanceOf(Uint8Array); + }); + + it('retries 5xx up to 3 times then succeeds', async () => { + let attempts = 0; + const fetchMock = vi.fn().mockImplementation(() => { + attempts++; + if (attempts < 3) { + return Promise.resolve(new Response('temporary failure', { status: 503 })); + } + return Promise.resolve(new Response(null, { status: 200 })); + }); + + await uploadAssetsToR2([makeItem('/x.css', 'h-x')], cliLog, fetchMock); + + expect(fetchMock).toHaveBeenCalledTimes(3); + const retries = cliLog.warn.mock.calls.filter(([msg]) => msg === 'r2_put_retry'); + expect(retries).toHaveLength(2); + expect(retries[0][1]).toMatchObject({ attempt: 1, status: 503 }); + expect(retries[1][1]).toMatchObject({ attempt: 2, status: 503 }); + }); + + it('throws DirectR2PutError on 4xx without retry', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('SignatureMismatch', { status: 403 })); + + await expect( + uploadAssetsToR2([makeItem('/oops.html', 'h-oops')], cliLog, fetchMock), + ).rejects.toMatchObject({ + name: 'DirectR2PutError', + status: 403, + hash: 'h-oops', + path: '/oops.html', + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('throws DirectR2PutError when 5xx exceeds retry budget', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('boom', { status: 500 })); + + await expect( + uploadAssetsToR2([makeItem('/x.html', 'h-x')], cliLog, fetchMock), + ).rejects.toMatchObject({ + name: 'DirectR2PutError', + status: 500, + hash: 'h-x', + path: '/x.html', + }); + // 1 initial attempt + 3 retries = 4 calls. + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it('error message identifies the asset path and hash', () => { + const err = new DirectR2PutError(403, 'h-bad', '/foo.html', 'SignatureMismatch'); + expect(err.message).toContain('/foo.html'); + expect(err.message).toContain('h-bad'); + expect(err.message).toContain('403'); + expect(err.message).toContain('SignatureMismatch'); + }); + + it('respects bounded concurrency (max 10 in flight)', async () => { + let inFlight = 0; + let maxInFlight = 0; + const fetchMock = vi.fn().mockImplementation(async () => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + // Yield once so concurrent requests stack up. + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight--; + return new Response(null, { status: 200 }); + }); + + const items = Array.from({ length: 25 }, (_, i) => makeItem(`/f${i}.html`, `h${i}`)); + await uploadAssetsToR2(items, cliLog, fetchMock); + + expect(fetchMock).toHaveBeenCalledTimes(25); + expect(maxInFlight).toBeGreaterThan(1); + expect(maxInFlight).toBeLessThanOrEqual(10); + }); + + it('returns immediately when items is empty', async () => { + const fetchMock = vi.fn(); + await uploadAssetsToR2([], cliLog, fetchMock); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('surfaces network errors with retry exhaustion', async () => { + const fetchMock = vi.fn().mockRejectedValue(new TypeError('fetch failed')); + + await expect( + uploadAssetsToR2([makeItem('/n.html', 'h-n')], cliLog, fetchMock), + ).rejects.toMatchObject({ + name: 'DirectR2PutError', + status: 0, + hash: 'h-n', + path: '/n.html', + }); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); +}); From f70264b368d0c1db9242f3446bcff535540af43e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 7 May 2026 20:11:42 +0800 Subject: [PATCH 3/8] fix(cli/deploy): drop assetFiles refs as soon as R2 acks each PUT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit streamDirectR2Deploy held every asset's bytes in `assetFiles` until finalizeDeploy returned, even for entries preflight reported as already in R2. A 142 MB mostly-cached deploy paid full resident memory for nothing. Two-stage release: - Right after preflight, prune `assetFiles` to the `needed` subset so skipped entries become GC-eligible immediately. Pre/post sizes log to the JSONL trace as `asset_cache_pruned` for retrospect. - During upload, `uploadAssetsToR2` now clears `item.body` and invokes an `onAfterPut` callback after each successful PUT. The deploy driver passes `assetFiles.delete(item.path)` so peak memory under the bounded pool tracks `R2_PUT_CONCURRENCY × asset_size`, not the full bundle. Callback is suppressed on PUT failure so callers can still re-presign / retry without losing the buffer prematurely. Co-Authored-By: Claude Opus 4.7 --- packages/void/src/cli/deploy.ts | 27 ++++++- packages/void/src/cli/direct-r2-upload.ts | 32 +++++++- .../test/integration/direct-r2-deploy.test.ts | 77 +++++++++++++++++++ .../void/test/unit/direct-r2-upload.test.ts | 33 ++++++++ 4 files changed, 164 insertions(+), 5 deletions(-) diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index db27de101..2ab07d6e4 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -654,6 +654,26 @@ async function streamDirectR2Deploy( cliLog?: CliLogger, ): Promise { if (needed.length > 0) { + // Memory: drop buffers for assets the server already has so peak + // memory tracks `needed`, not the full bundle. Skipped entries become + // GC-eligible immediately rather than after `finalizeDeploy` returns. + // The pre-prune size is captured for the JSONL log only. We materialize + // the key list before deleting so the iterator isn't disturbed mid-walk. + const neededPaths = new Set(needed); + const preBufferCount = assetFiles.size; + const allPaths = Array.from(assetFiles.keys()); + for (const path of allPaths) { + if (!neededPaths.has(path)) { + assetFiles.delete(path); + } + } + if (assetFiles.size !== preBufferCount) { + cliLog?.info('asset_cache_pruned', { + before: preBufferCount, + after: assetFiles.size, + }); + } + s.message(`Requesting upload URLs for ${needed.length} asset(s)...`); const assetsToUpload = needed.map((path) => { @@ -710,7 +730,12 @@ async function streamDirectR2Deploy( }); s.message(`Uploading ${items.length} asset(s) to R2...`); - await uploadAssetsToR2(items, cliLog); + // Per-PUT cleanup: once R2 acks the upload, drop the buffer from the + // CLI-side cache so peak memory under the bounded upload pool stays + // close to `R2_PUT_CONCURRENCY` × asset size, not the full bundle. + await uploadAssetsToR2(items, cliLog, undefined, (item) => { + assetFiles.delete(item.path); + }); } else { cliLog?.info('presign_skipped', { reason: 'no_needed_assets' }); } diff --git a/packages/void/src/cli/direct-r2-upload.ts b/packages/void/src/cli/direct-r2-upload.ts index 25456575f..657941137 100644 --- a/packages/void/src/cli/direct-r2-upload.ts +++ b/packages/void/src/cli/direct-r2-upload.ts @@ -47,8 +47,13 @@ export interface DirectR2PutItem { path: string; /** BLAKE3 storage hash — used for log correlation and R2 key. */ hash: string; - /** Asset bytes. */ - body: Buffer; + /** + * Asset bytes. Cleared (set to `undefined`) by `uploadAssetsToR2` after a + * successful PUT so the buffer becomes eligible for GC as soon as the + * upload is acknowledged. Callers should not read `body` after the upload + * completes. + */ + body: Buffer | undefined; /** Presigned URL from `requestUploadUrls`. */ url: string; /** Headers the platform required us to send (incl. Content-Md5, Content-Type). */ @@ -63,11 +68,17 @@ export interface DirectR2PutItem { * * Logs `r2_put_start` / `r2_put_end` per asset, plus `r2_put_retry` on * each retry attempt. Per-asset durations help identify slow uploads. + * + * Memory: each successful PUT clears `item.body` and invokes `onAfterPut` + * so the caller can drop its own ref (e.g. delete from the in-memory + * `assetFiles` cache). For huge deploys this keeps peak memory bounded by + * the upload concurrency rather than the total bundle size. */ export async function uploadAssetsToR2( items: Array, cliLog: CliLogger | undefined, fetchImpl: typeof fetch = fetch, + onAfterPut?: (item: DirectR2PutItem) => void, ): Promise { if (items.length === 0) { return; @@ -97,6 +108,12 @@ export async function uploadAssetsToR2( } return; } + // Drop refs to the asset bytes now that R2 has them. Both the + // local `body` ref on the item and any caller-side ref (via + // `onAfterPut`) are released so the buffer becomes eligible + // for GC under bounded concurrency. + items[i].body = undefined; + onAfterPut?.(items[i]); } })(), ); @@ -112,11 +129,18 @@ async function uploadOne( cliLog: CliLogger | undefined, fetchImpl: typeof fetch, ): Promise { + // Capture the body up-front. The pool only clears `item.body` AFTER + // `uploadOne` returns successfully, so it is guaranteed defined here for + // all attempts of this single PUT. + if (!item.body) { + throw new Error(`internal: uploadOne invoked for ${item.path} with no body`); + } + const body = item.body; const startedAt = Date.now(); cliLog?.info('r2_put_start', { hash: item.hash, path: item.path, - sizeBytes: item.body.length, + sizeBytes: body.length, }); let attempt = 0; @@ -128,7 +152,7 @@ async function uploadOne( // Clone the buffer into a fresh Uint8Array view so each retry sees // an independent body. Reusing the same Buffer across retries is // safe with `fetch` (it consumes once), but explicit here. - body: new Uint8Array(item.body) as unknown as BodyInit, + body: new Uint8Array(body) as unknown as BodyInit, headers: item.headers, }); } catch (err) { diff --git a/packages/void/test/integration/direct-r2-deploy.test.ts b/packages/void/test/integration/direct-r2-deploy.test.ts index b0bbcf3a1..cfe0fb628 100644 --- a/packages/void/test/integration/direct-r2-deploy.test.ts +++ b/packages/void/test/integration/direct-r2-deploy.test.ts @@ -324,6 +324,83 @@ describe('direct-to-R2 deploy flow', () => { expect(uniquePaths.size).toBe(4); }); + it('prunes the in-memory assetFiles cache to needed-only after preflight', async () => { + // 4 fixture assets, only 1 needed: pruning should drop 3 buffers + // immediately after preflight, and the per-PUT cleanup should drop + // the last one once R2 acks the upload. We assert via the JSONL log, + // which captures both the pre/post-prune sizes (`asset_cache_pruned`) + // and the upload events (`r2_put_end`). + installPlatformMocks({ + preflightNeeded: ['/index.html'], + preflightSkipped: 3, + }); + const cliLog = openDeployLog(); + const logPath = cliLog.path; + expect(logPath).toBeTruthy(); + const client = new PlatformClient('test-token', { apiUrl: 'https://api.test', cliLog }); + + await runStaticDeploy( + workdir, + { projectId: 'proj_t', slug: 'test' }, + client, + { buildCommand: null, outputDir: assetsDir, appType: 'spa' }, + true, + null, + undefined, + undefined, + undefined, + undefined, + undefined, + cliLog, + false, + ); + + await cliLog.close(); + const lines = readFileSync(logPath as string, 'utf-8') + .trim() + .split('\n') + .map((l) => JSON.parse(l) as { msg: string; before?: number; after?: number }); + const prune = lines.find((l) => l.msg === 'asset_cache_pruned'); + expect(prune).toBeDefined(); + // Before prune: 4 fixture buffers in the cache. After: 1 (the only + // needed asset). Per-PUT cleanup runs after this event so the log + // value here is the post-prune-but-pre-PUT size. + expect(prune?.before).toBe(4); + expect(prune?.after).toBe(1); + }); + + it('does not log asset_cache_pruned when nothing was skipped', async () => { + // All 4 needed → prune is a no-op and we suppress the noisy log. + installPlatformMocks(); + const cliLog = openDeployLog(); + const logPath = cliLog.path; + expect(logPath).toBeTruthy(); + const client = new PlatformClient('test-token', { apiUrl: 'https://api.test', cliLog }); + + await runStaticDeploy( + workdir, + { projectId: 'proj_t', slug: 'test' }, + client, + { buildCommand: null, outputDir: assetsDir, appType: 'spa' }, + true, + null, + undefined, + undefined, + undefined, + undefined, + undefined, + cliLog, + false, + ); + + await cliLog.close(); + const lines = readFileSync(logPath as string, 'utf-8') + .trim() + .split('\n') + .map((l) => JSON.parse(l) as { msg: string }); + expect(lines.find((l) => l.msg === 'asset_cache_pruned')).toBeUndefined(); + }); + it('skips upload-urls + R2 PUT phase when no assets are needed', async () => { const state = installPlatformMocks({ preflightNeeded: [], preflightSkipped: 4 }); const client = makeClient(); diff --git a/packages/void/test/unit/direct-r2-upload.test.ts b/packages/void/test/unit/direct-r2-upload.test.ts index aa11b2cd0..e24c6d863 100644 --- a/packages/void/test/unit/direct-r2-upload.test.ts +++ b/packages/void/test/unit/direct-r2-upload.test.ts @@ -160,4 +160,37 @@ describe('uploadAssetsToR2', () => { }); expect(fetchMock).toHaveBeenCalledTimes(4); }); + + it('clears item.body and invokes onAfterPut after each successful PUT', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + const items = [makeItem('/a.html', 'h1'), makeItem('/b.css', 'h2'), makeItem('/c.js', 'h3')]; + const seen: Array = []; + + await uploadAssetsToR2(items, cliLog, fetchMock, (item) => { + // body must already be cleared by the time the callback runs so the + // caller does not have to inspect it. + expect(item.body).toBeUndefined(); + seen.push(item.path); + }); + + // Every item invoked the callback exactly once, with body cleared. + expect(seen.sort()).toEqual(['/a.html', '/b.css', '/c.js']); + for (const item of items) { + expect(item.body).toBeUndefined(); + } + }); + + it('does not invoke onAfterPut when the PUT fails', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('SignatureMismatch', { status: 403 })); + const item = makeItem('/oops.html', 'h-oops'); + const onAfterPut = vi.fn(); + + await expect(uploadAssetsToR2([item], cliLog, fetchMock, onAfterPut)).rejects.toBeDefined(); + + // Failed upload must not signal "done" via the cleanup hook — callers + // depend on this to avoid prematurely dropping refs they may need to + // re-presign / retry against in a future iteration. + expect(onAfterPut).not.toHaveBeenCalled(); + expect(item.body).toBeDefined(); + }); }); From 9c106e219c501867a2948b2e6e9d47b8365a43a7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 7 May 2026 21:03:28 +0800 Subject: [PATCH 4/8] fix(cli/deploy): pass undefined for gitMetadata in remaining test sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more sites in direct-r2-deploy.test.ts at lines 348 and 386 still passed `null` for the 6th positional arg to runStaticDeploy, which post-rebase is `gitMetadata?: GitDeployMetadata` (was `commit: string | null`). The other 9 sites were updated in the rebase commit; these two were missed because they sit inside helper functions whose calls weren't in the same edit batch. Pure type-only fix — no behavior change. Co-Authored-By: Claude Opus 4.7 --- packages/void/test/integration/direct-r2-deploy.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/void/test/integration/direct-r2-deploy.test.ts b/packages/void/test/integration/direct-r2-deploy.test.ts index cfe0fb628..6f7e24cfb 100644 --- a/packages/void/test/integration/direct-r2-deploy.test.ts +++ b/packages/void/test/integration/direct-r2-deploy.test.ts @@ -345,7 +345,7 @@ describe('direct-to-R2 deploy flow', () => { client, { buildCommand: null, outputDir: assetsDir, appType: 'spa' }, true, - null, + undefined, undefined, undefined, undefined, @@ -383,7 +383,7 @@ describe('direct-to-R2 deploy flow', () => { client, { buildCommand: null, outputDir: assetsDir, appType: 'spa' }, true, - null, + undefined, undefined, undefined, undefined, From baa01cd5f5838d0f9ea6406b949a29118ba220a0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 7 May 2026 21:03:49 +0800 Subject: [PATCH 5/8] fix(cli/deploy): dedupe presign + R2 PUT by content hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `streamDirectR2Deploy` iterated `needed` (paths) when building both `assetsToUpload` (presign request) and `items` (R2 PUT pool), so identical files (same `blake3`) produced one presign request and one PUT each. The platform's `upload-urls` handler tolerates duplicate blake3 by last-write-wins on the response map (which then collapses to one entry), but the CLI still re-fanned-out N PUTs to the same R2 key — wasted bandwidth, larger retry surface, and amplified load on bundles with prerendered/duplicated content. Now we walk `needed` once, collect the unique-by-blake3 path list, and both arrays iterate that. The `assetManifest` payload sent to `finalizeDeploy` is unchanged — every path stays so the platform builds the per-path serving manifest. The `presign_request_start` log gains a `dedupedFrom: needed.length` field for observability. `onAfterPut` still keys by path, so buffers for non-chosen-but-content- duplicate paths stay in `assetFiles` until the function returns. Bounded by bundle size, not a correctness issue, and pruning all sibling paths would require a hash→paths reverse map — left for a follow-up. Adds an integration test covering: presign request has unique-blake3 entries only, exactly one R2 PUT per unique hash, and `finalizeDeploy` payload's assetManifest still contains every path. Co-Authored-By: Claude Opus 4.7 --- packages/void/src/cli/deploy.ts | 31 ++++++++-- .../test/integration/direct-r2-deploy.test.ts | 61 +++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 2ab07d6e4..284a37a69 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -676,7 +676,14 @@ async function streamDirectR2Deploy( s.message(`Requesting upload URLs for ${needed.length} asset(s)...`); - const assetsToUpload = needed.map((path) => { + // Dedupe by content hash: identical files (same blake3) get presigned + // and PUT once. `assetManifest` still lists every path for the + // per-path serving manifest the platform builds in `finalizeDeploy`; + // this only collapses the upload phase. Common with prerendered HTML, + // duplicated images, and framework-emitted twins. + const seenBlake3 = new Set(); + const uniqueNeeded: Array = []; + for (const path of needed) { const entry = assetManifest[path]; if (!entry) { // Should never fire — `needed` is preflight's projection of @@ -684,6 +691,15 @@ async function streamDirectR2Deploy( // future preflight regression slips through. throw new Error(`internal: preflight returned ${path} but assetManifest has no entry`); } + if (seenBlake3.has(entry.blake3)) { + continue; + } + seenBlake3.add(entry.blake3); + uniqueNeeded.push(path); + } + + const assetsToUpload = uniqueNeeded.map((path) => { + const entry = assetManifest[path]; return { blake3: entry.blake3, md5: entry.md5, @@ -692,7 +708,10 @@ async function streamDirectR2Deploy( }; }); - cliLog?.info('presign_request_start', { assetCount: assetsToUpload.length }); + cliLog?.info('presign_request_start', { + assetCount: assetsToUpload.length, + dedupedFrom: needed.length, + }); const presignStartedAt = Date.now(); const urls = await client.requestUploadUrls(projectId, assetsToUpload); cliLog?.info('presign_request_end', { @@ -700,9 +719,9 @@ async function streamDirectR2Deploy( durationMs: Date.now() - presignStartedAt, }); - // Map needed paths → DirectR2PutItem, with the asset bytes pulled from - // the in-memory cache (no disk reads here). - const items: Array = needed.map((path) => { + // Map unique-by-hash paths → DirectR2PutItem, with the asset bytes + // pulled from the in-memory cache (no disk reads here). + const items: Array = uniqueNeeded.map((path) => { const entry = assetManifest[path]; const presigned = urls[entry.blake3]; if (!presigned) { @@ -713,7 +732,7 @@ async function streamDirectR2Deploy( } const body = assetFiles.get(path); if (!body) { - // Defensive — `needed` came from `assetManifest`, which was + // Defensive — `uniqueNeeded` came from `assetManifest`, which was // constructed from `assetFiles`. If this fires, the upstream // collection diverged from preflight. throw new Error( diff --git a/packages/void/test/integration/direct-r2-deploy.test.ts b/packages/void/test/integration/direct-r2-deploy.test.ts index 6f7e24cfb..244fa52cb 100644 --- a/packages/void/test/integration/direct-r2-deploy.test.ts +++ b/packages/void/test/integration/direct-r2-deploy.test.ts @@ -601,6 +601,67 @@ describe('direct-to-R2 deploy flow', () => { expect(events).toContain('finalize_end'); }); + it('dedupes presign + R2 PUT by content hash when paths share blake3', async () => { + // Two files with identical content → identical blake3. The upload + // phase must collapse to ONE presign + ONE PUT, but the final + // assetManifest must still list BOTH paths (the platform builds the + // per-path serving manifest from it). + // + // We build a fresh fixture: keep `index.html` and `style.css` from + // the default set, plus two extra files with the same identical + // content so the duplicates are an explicit pair. + const sharedHtml = 'shared\n'; + const dupeRel = '/about.html'; + const dupeRel2 = '/contact.html'; + writeFileSync(join(assetsDir, dupeRel.slice(1)), sharedHtml); + writeFileSync(join(assetsDir, dupeRel2.slice(1)), sharedHtml); + + const allPaths = [...Object.keys(fixtureFiles), dupeRel, dupeRel2]; + const state = installPlatformMocks({ preflightNeeded: allPaths }); + const client = makeClient(); + + await runStaticDeploy( + workdir, + { projectId: 'proj_t', slug: 'test' }, + client, + { buildCommand: null, outputDir: assetsDir, appType: 'spa' }, + true, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + false, + ); + + // 6 paths total but only 5 unique blake3 values (about.html == + // contact.html). Presign request body MUST contain 5 entries, + // R2 PUTs MUST be 5, NOT 6. + expect(state.uploadUrlsCalls).toHaveLength(1); + const presign = state.uploadUrlsCalls[0] as { + assets: Array<{ blake3: string }>; + }; + expect(presign.assets).toHaveLength(5); + const presignHashes = new Set(presign.assets.map((a) => a.blake3)); + expect(presignHashes.size).toBe(5); + + expect(state.r2PutCalls).toHaveLength(5); + const r2Hashes = new Set(state.r2PutCalls.map(({ url }) => url.split('/').pop())); + expect(r2Hashes.size).toBe(5); + + // The finalize payload's assetManifest still includes BOTH duplicate + // paths so the platform-side per-path serving manifest is complete. + expect(state.finalizeCalls).toHaveLength(1); + const finalizeBody = JSON.parse(state.finalizeCalls[0].init.body as string); + expect(finalizeBody.assetManifest[dupeRel]).toBeDefined(); + expect(finalizeBody.assetManifest[dupeRel2]).toBeDefined(); + expect(finalizeBody.assetManifest[dupeRel].blake3).toBe( + finalizeBody.assetManifest[dupeRel2].blake3, + ); + }); + it('failure-mode error message ends with the JSONL log path', async () => { installPlatformMocks({ finalizeEvents: [{ event: 'error', message: 'something broke', step: 'finalize' }], From 5a0262eb83a91cb513a11fb2f2d59d1e666f9d91 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 7 May 2026 21:11:10 +0800 Subject: [PATCH 6/8] =?UTF-8?q?refactor(cli/package):=20keep=20buildVoidMa?= =?UTF-8?q?nifest=20pure=20=E2=80=94=20move=20warning=20to=20callers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildVoidManifest`'s docstring claims "Pure shape transformation: reads no files, has no I/O", but the SPA-fallback branch was calling `onProgress?.(msg)` and `console.warn(...)` for the non-SPA-fallbacks mismatch. Cursor flagged the contract mismatch. The warning condition only depends on inputs the callers already have (`assetConfig`, `fallbackRules`), so factor it out to a pure helper `detectNonSpaFallbackWarning(assetConfig, fallbackRules) -> string | null` and have the two callers (`packageBuild` for the legacy multipart path, `runFullDeploy`'s direct-r2 branch for JSON-mode) emit it before invoking the builder. The `onProgress` parameter is dropped from `buildVoidManifest`'s opts. User-visible warning text and fanout (progress callback + `console.warn`) are unchanged. `buildFrameworkManifest` and `buildStaticManifest` were already clean — the bot's "Additional Locations: 1" hint was wrong. Co-Authored-By: Claude Opus 4.7 --- packages/void/src/cli/deploy.ts | 11 ++++++- packages/void/src/cli/package.ts | 50 ++++++++++++++++++++++++++------ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 284a37a69..6a1ad7ab4 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -79,6 +79,7 @@ import { buildStaticManifest, buildFrameworkManifest, collectWorkerFiles, + detectNonSpaFallbackWarning, getAssetContentType, type AssetManifestEntry, } from './package.ts'; @@ -1718,6 +1719,15 @@ async function runFullDeploy( }); result = await streamDeploy(client, config.projectId, formData, s, cliLog); } else { + // Surface the non-SPA-fallbacks mismatch (if any) before invoking the + // I/O-free manifest builder. Mirrors what `packageBuild` does on the + // legacy multipart path so both deploy modes warn identically. + const fallbackWarning = detectNonSpaFallbackWarning(assetConfig, fallbackRules); + if (fallbackWarning) { + onProgress?.(fallbackWarning); + console.warn(`[void] ${fallbackWarning}`); + } + const manifest = buildVoidManifest({ bindings: effectiveBindings, migrations: validatedMigrations, @@ -1736,7 +1746,6 @@ async function runFullDeploy( fallbackRules, dialect, webSockets, - onProgress, }); const workerFiles = collectWorkerFiles(join(distDir, workerDirName)); cliLog?.info('package_end', { diff --git a/packages/void/src/cli/package.ts b/packages/void/src/cli/package.ts index ec0696e32..8cfa6886a 100644 --- a/packages/void/src/cli/package.ts +++ b/packages/void/src/cli/package.ts @@ -441,6 +441,30 @@ function toSandboxManifest(sandbox: ResolvedSandboxConfig): SandboxManifestMeta }; } +/** + * If the user configured `routing.fallbacks` on a non-SPA app, the platform + * still applies the rules but no `/* -> /index.html` SPA shell is synthesised + * — unmatched paths fall through to the platform 404. Returns the warning + * message string when that mismatch is detected, or `null` otherwise. Pure + * function; callers decide whether/how to surface the message (CLI progress, + * `console.warn`, etc.). + */ +export function detectNonSpaFallbackWarning( + assetConfig: AssetConfig | undefined, + fallbackRules: Array | undefined, +): string | null { + if (assetConfig?.not_found_handling === 'single-page-application') { + return null; + } + if (!fallbackRules || fallbackRules.length === 0) { + return null; + } + return ( + `routing.fallbacks is set but appType is not "spa"; fallbacks will be applied, ` + + `but no "/* -> /index.html" SPA shell fallback is synthesised — unmatched paths fall through to the platform 404.` + ); +} + /** * Build a Void-app deploy manifest from the inferred bindings + per-feature * inputs. Pure shape transformation: reads no files, has no I/O. Both the @@ -448,6 +472,10 @@ function toSandboxManifest(sandbox: ResolvedSandboxConfig): SandboxManifestMeta * (`prepareVoidDeployBundle`) call into this so the manifest is computed * once per deploy and the wire shape stays bit-for-bit identical between * the two paths. + * + * Note: callers are responsible for surfacing the non-SPA-fallbacks warning + * (see `detectNonSpaFallbackWarning`) before invoking this builder. The + * builder itself stays I/O-free. */ export function buildVoidManifest(opts: { bindings: InferredBindings; @@ -467,7 +495,6 @@ export function buildVoidManifest(opts: { fallbackRules?: Array; dialect?: 'sqlite' | 'postgresql'; webSockets?: Array; - onProgress?: ProgressCallback; }): DeployManifest { const { bindings, @@ -487,7 +514,6 @@ export function buildVoidManifest(opts: { fallbackRules, dialect, webSockets, - onProgress, } = opts; const manifest: DeployManifest = { version: 3, assetManifestVersion: 2, bindings: {} }; @@ -566,18 +592,16 @@ export function buildVoidManifest(opts: { // platform-level SPA switch and append a synthetic catch-all rule so user // fallbacks are evaluated first and the SPA default only fires when none // matched. + // + // The non-SPA-fallbacks mismatch warning that used to live here moved to + // the callers (see `detectNonSpaFallbackWarning`); this function stays + // I/O-free per its documented contract. let resolvedAssetConfig = assetConfig; let resolvedFallbackRules = fallbackRules; if (assetConfig?.not_found_handling === 'single-page-application') { const chain = resolveSpaFallbackChain('spa', fallbackRules); resolvedAssetConfig = { ...assetConfig, ...chain.assetConfig }; resolvedFallbackRules = chain.fallbackRules; - } else if (fallbackRules && fallbackRules.length > 0) { - const msg = - `routing.fallbacks is set but appType is not "spa"; fallbacks will be applied, ` + - `but no "/* -> /index.html" SPA shell fallback is synthesised — unmatched paths fall through to the platform 404.`; - onProgress?.(msg); - console.warn(`[void] ${msg}`); } if (resolvedAssetConfig) { @@ -685,6 +709,15 @@ export async function packageBuild( ): Promise { const formData = new FormData(); + // Surface the non-SPA-fallbacks mismatch (if any) here, before invoking + // the I/O-free manifest builder. Mirrors what `runFullDeploy` does on the + // direct-to-R2 path so both deploy modes warn identically. + const fallbackWarning = detectNonSpaFallbackWarning(assetConfig, fallbackRules); + if (fallbackWarning) { + onProgress?.(fallbackWarning); + console.warn(`[void] ${fallbackWarning}`); + } + const manifest = buildVoidManifest({ bindings, migrations, @@ -703,7 +736,6 @@ export async function packageBuild( fallbackRules, dialect, webSockets, - onProgress, }); if (migrations && migrations.length > 0) { From 5c22706c358f94ad0a0fa3057828efb6b535bb2b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 7 May 2026 21:11:18 +0800 Subject: [PATCH 7/8] refactor(cli/client): drop unused vars field on ManifestOnlyDeployPayload The optional `vars?: Record` field on `ManifestOnlyDeployPayload` was added with the original direct-to-R2 commit and never populated by any code path. Env vars travel inside `manifest.vars` via `buildVoidManifest`/`buildFrameworkManifest`. Drop the field to remove confusion about where vars are expected on the wire. The platform's `handleManifestOnlyDeploy` had a defensive `body.vars` reader for the same dead field; cleaned up in the platform commit that bumps this submodule pointer. Co-Authored-By: Claude Opus 4.7 --- packages/void/src/cli/client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index ba87972b8..3662c9b39 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -151,7 +151,6 @@ export interface ManifestOnlyDeployPayload { commitUrl: string | null; gitRef: string | null; preflight: 'true' | null; - vars?: Record; /** Worker module bytes keyed by relative path, base64-encoded. */ workerFiles?: Record; } From 6e1f40471787826f5a4857498fbca62426342017 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 7 May 2026 21:34:35 +0800 Subject: [PATCH 8/8] refactor(cli): remove legacy multipart deploy upload path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The direct-to-R2 deploy path (design doc 0068) is now the only supported flow. Drop the `--legacy-upload` flag and the entire multipart packager: - args/cli/help/docs: drop the `--legacy-upload` flag - deploy.ts: drop legacyUpload branches, streamDeploy(), appendDeployMetadata() - package.ts: drop packageBuild, packageStaticBuild, packageFrameworkBuild, appendAssetsFromCache, appendAssets — keep collectAndHashAssets, the manifest builders, and collectWorkerFiles for the direct-R2 flow - client.ts: drop the multipart deploy() method, keep finalizeDeploy(), requestUploadUrls, preflight, and friends - tests: drop the multipart cases from package.test.ts and direct-r2-deploy.test.ts; rewrite migration-binding-consistency to exercise buildVoidManifest / buildFrameworkManifest directly Old CLI versions on npm will fail against the new platform until users upgrade — accepted per the rollout plan, since CLI and platform ship together. --- docs/reference/cli.md | 17 +- packages/void/src/cli/args.ts | 5 +- packages/void/src/cli/cli.ts | 1 - packages/void/src/cli/client.ts | 22 +- packages/void/src/cli/deploy.ts | 373 +++------ packages/void/src/cli/help.ts | 5 - packages/void/src/cli/package.ts | 361 +-------- .../test/integration/direct-r2-deploy.test.ts | 93 +-- .../migration-binding-consistency.test.ts | 299 ++----- packages/void/test/unit/package.test.ts | 744 +----------------- 10 files changed, 210 insertions(+), 1710 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a674c4db2..245f70513 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -203,21 +203,20 @@ If `--project` is provided, purges that project's cache instead of the linked pr ### `void deploy` ``` -void deploy [--project ] [--dir ] [--spa] [--skip-build] [--debug] [--legacy-upload] +void deploy [--project ] [--dir ] [--spa] [--skip-build] [--debug] ``` Auto-detects your project type and chooses the right pipeline. See [Supported App Types](../guide/app-types.md) and [Deployment](../guide/deployment.md) for details. For Drizzle projects, deploy performs a read-only schema drift check. If a new migration would be generated, deploy stops and tells you to run `void db generate`, review the migration, commit it yourself, and rerun `void deploy`. -| Flag | Purpose | -| ------------------ | ----------------------------------------------------------------------------------------------------------- | -| `--project ` | Target a specific project by slug | -| `--dir ` | Deploy a pre-built static directory (skips build) | -| `--spa` | Use SPA mode instead of SSG for static deploys | -| `--skip-build` | Skip the build step (use existing build output) | -| `--debug` | Mirror the structured deploy log to stderr (also written to `~/.void/logs/`) | -| `--legacy-upload` | Use the legacy multipart upload path. For backward compatibility only; will be removed in a future release. | +| Flag | Purpose | +| ------------------ | ---------------------------------------------------------------------------- | +| `--project ` | Target a specific project by slug | +| `--dir ` | Deploy a pre-built static directory (skips build) | +| `--spa` | Use SPA mode instead of SSG for static deploys | +| `--skip-build` | Skip the build step (use existing build output) | +| `--debug` | Mirror the structured deploy log to stderr (also written to `~/.void/logs/`) | Every deploy writes a structured JSONL trace to `~/.void/logs/deploy-.jsonl` regardless of `--debug`. On failure the path is printed at the end of the error message so you can attach it when reporting platform issues. `VOID_DEPLOY_DEBUG=1` is accepted as an alternate trigger for stderr mirroring. diff --git a/packages/void/src/cli/args.ts b/packages/void/src/cli/args.ts index aea246c42..d30007ba0 100644 --- a/packages/void/src/cli/args.ts +++ b/packages/void/src/cli/args.ts @@ -6,7 +6,6 @@ export interface DeployArgs { spa?: boolean; skipBuild?: boolean; debug?: boolean; - legacyUpload?: boolean; } export function parseDeployArgs(args: Array): DeployArgs { @@ -20,7 +19,6 @@ export function parseDeployArgs(args: Array): DeployArgs { spa: { type: 'boolean' }, 'skip-build': { type: 'boolean' }, debug: { type: 'boolean' }, - 'legacy-upload': { type: 'boolean' }, }, allowPositionals: true, strict: true, @@ -63,7 +61,6 @@ export function parseDeployArgs(args: Array): DeployArgs { const spa = parsed.values.spa === true ? true : undefined; const skipBuild = parsed.values['skip-build'] === true ? true : undefined; const debug = parsed.values.debug === true ? true : undefined; - const legacyUpload = parsed.values['legacy-upload'] === true ? true : undefined; - return { projectSlug, dir, spa, skipBuild, debug, legacyUpload }; + return { projectSlug, dir, spa, skipBuild, debug }; } diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index ddf83a242..9630068b8 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -99,7 +99,6 @@ if (helpPath) { spa: deployArgs.spa, skipBuild: deployArgs.skipBuild, debug: deployArgs.debug, - legacyUpload: deployArgs.legacyUpload, }).catch((err) => { log.error(err.message ?? err); process.exit(1); diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 3662c9b39..18a869684 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -325,17 +325,6 @@ export class PlatformClient { } } - async *deploy(projectId: string, formData: FormData): AsyncGenerator { - const url = `${this.baseUrl}/projects/${projectId}/deploy`; - yield* this.streamDeployRequest(url, { - method: 'POST', - headers: { - ...this.authHeaders, - }, - body: formData, - }); - } - /** * Request presigned R2 PUT URLs for a batch of assets. Each entry carries * both `blake3` (storage key) and `md5` (wire-level integrity primitive) @@ -379,9 +368,8 @@ export class PlatformClient { /** * Manifest-only (direct-to-R2) deploy. POSTs a JSON body containing the * deploy manifest, asset manifest (no asset bytes), and any worker module - * bytes (base64-encoded). Streams the same NDJSON response shape as the - * legacy multipart `deploy(...)` path, so the caller can apply events with - * the existing `applyDeployEvent` machinery. + * bytes (base64-encoded). Streams an NDJSON response that the caller can + * apply with the existing `applyDeployEvent` machinery. */ async *finalizeDeploy( projectId: string, @@ -399,10 +387,8 @@ export class PlatformClient { } /** - * Shared POST + NDJSON-stream-reader for the multipart `deploy()` and the - * JSON-mode `finalizeDeploy()` paths. Identical wire framing on both, so - * the loop is factored here once. Captures `cf-ray` + `cf-cache-status` - * for postmortem correlation. + * POST + NDJSON-stream-reader used by `finalizeDeploy()`. Captures + * `cf-ray` + `cf-cache-status` for postmortem correlation. */ private async *streamDeployRequest(url: string, init: RequestInit): AsyncGenerator { const cliLog = this.cliLog; diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 6a1ad7ab4..c562f7443 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -71,9 +71,6 @@ import { openDeployLog, type CliLogger } from './log-file.ts'; import { promptForLoginToken } from './login.ts'; import { cliTitle, createSpinner, pc } from './output.ts'; import { - packageBuild, - packageStaticBuild, - packageFrameworkBuild, collectAndHashAssets, buildVoidManifest, buildStaticManifest, @@ -113,13 +110,6 @@ export interface RunDeployOptions { spa?: boolean; skipBuild?: boolean; debug?: boolean; - /** - * When true, the CLI uses the legacy multipart `POST /:id/deploy` upload - * path instead of the default direct-to-R2 + JSON-mode finalize flow. - * Kept as an escape hatch for one release while the new path beds in; - * scheduled for removal once direct-to-R2 is proven on production traffic. - */ - legacyUpload?: boolean; } type DeployDoneEvent = Extract; @@ -322,7 +312,6 @@ async function runDeployInner( options?.skipBuild, gitMetadata, cliLog, - options?.legacyUpload, ); } else { // Class A: preset is optional (e.g. vinext probes at runtime) @@ -335,7 +324,6 @@ async function runDeployInner( gitMetadata, detected, cliLog, - options?.legacyUpload, ); } return; @@ -394,7 +382,6 @@ async function runDeployInner( undefined, voidConfig.routing, cliLog, - options?.legacyUpload, ); } else { await runFullDeploy( @@ -406,7 +393,6 @@ async function runDeployInner( gitMetadata, undefined, cliLog, - options?.legacyUpload, ); } return; @@ -538,9 +524,7 @@ export function applyDeployEvent(event: DeployEvent, s: SpinnerResult): ApplyDep /** * Apply a streamed `DeployEvent` iterator to the spinner / cliLog and * resolve with the terminal `done` event (or throw on terminal error / - * connection loss). Path-agnostic: the legacy multipart deploy and the - * new direct-to-R2 finalize call both produce the same NDJSON event - * stream, so this loop is shared between them. + * connection loss). */ async function streamDeployEvents( projectId: string, @@ -615,22 +599,10 @@ async function streamDeployEvents( throw new Error(formatConnectionLostMessage(deploymentId, cliLog?.path ?? null)); } -async function streamDeploy( - client: PlatformClient, - projectId: string, - formData: FormData, - s: SpinnerResult, - cliLog?: CliLogger, -): Promise { - return streamDeployEvents(projectId, client.deploy(projectId, formData), s, cliLog); -} - /** * Direct-to-R2 deploy orchestration. Issues presigned PUT URLs for each * needed asset, uploads bytes directly to R2, then calls the manifest-only - * deploy endpoint to finalize. Returns the same `StreamDeployResult` shape - * as the legacy multipart `streamDeploy` so callers downstream stay - * path-agnostic. + * deploy endpoint to finalize. * * Key invariants: * 1. No needed assets → skip presign + R2 PUT phase entirely (saves a @@ -807,22 +779,6 @@ function formatKnownAssetSummary(result: DeployDoneEvent, skipped: number): stri : `${result.assets} static asset(s)`; } -function appendDeployMetadata(formData: FormData, gitMetadata?: GitDeployMetadata): void { - formData.append('source', getTokenSource()); - if (gitMetadata?.commit) { - formData.append('commit', gitMetadata.commit); - } - if (gitMetadata?.repositoryUrl) { - formData.append('repositoryUrl', gitMetadata.repositoryUrl); - } - if (gitMetadata?.commitUrl) { - formData.append('commitUrl', gitMetadata.commitUrl); - } - if (gitMetadata?.gitRef) { - formData.append('gitRef', gitMetadata.gitRef); - } -} - export async function runStaticDeploy( root: string, config: ProjectConfig, @@ -836,7 +792,6 @@ export async function runStaticDeploy( fallbackRules?: Array, routing?: RoutingConfig, cliLog?: CliLogger, - legacyUpload?: boolean, ): Promise { const typeLabel = preset.appType === 'spa' ? 'Static SPA' : 'Static Site'; log.info(`${typeLabel} deploy`); @@ -893,56 +848,34 @@ export async function runStaticDeploy( } s.start('Packaging...'); - cliLog?.info('package_start', { mode: 'static', legacyUpload: legacyUpload === true }); + cliLog?.info('package_start', { mode: 'static' }); const packageStartedAt = Date.now(); const neededSet = skipped > 0 ? new Set(needed) : null; - let result: StreamDeployResult; - if (legacyUpload) { - const formData = await packageStaticBuild( - preset.outputDir, - preset.appType, - neededSet, - hashedAssetsPrefix ?? 'assets', - headerRules, - redirectRules, - fallbackRules, - onProgress, - assetFiles, - ); - formData.append('assetManifest', JSON.stringify(assetManifest)); - appendDeployMetadata(formData, gitMetadata); - cliLog?.info('package_end', { - mode: 'static', - durationMs: Date.now() - packageStartedAt, - }); - result = await streamDeploy(client, config.projectId, formData, s, cliLog); - } else { - const manifest = buildStaticManifest({ - appType: preset.appType, - hashedAssetsPrefix: hashedAssetsPrefix ?? 'assets', - headerRules, - redirectRules, - fallbackRules, - }); - cliLog?.info('package_end', { - mode: 'static', - durationMs: Date.now() - packageStartedAt, - }); - result = await streamDirectR2Deploy( - client, - config.projectId, - manifest, - assetManifest, - assetFiles, - needed, - gitMetadata, - neededSet !== null, - undefined, - s, - cliLog, - ); - } + const manifest = buildStaticManifest({ + appType: preset.appType, + hashedAssetsPrefix: hashedAssetsPrefix ?? 'assets', + headerRules, + redirectRules, + fallbackRules, + }); + cliLog?.info('package_end', { + mode: 'static', + durationMs: Date.now() - packageStartedAt, + }); + const result = await streamDirectR2Deploy( + client, + config.projectId, + manifest, + assetManifest, + assetFiles, + needed, + gitMetadata, + neededSet !== null, + undefined, + s, + cliLog, + ); const summary = [`${typeLabel} — ${formatKnownAssetSummary(result, skipped)}`]; note(summary.join('\n'), result.url); @@ -979,7 +912,6 @@ async function runFrameworkDeploy( skipBuild?: boolean, gitMetadata?: GitDeployMetadata, cliLog?: CliLogger, - legacyUpload?: boolean, ): Promise { log.info(`${detected.name} framework deploy`); cliLog?.info('deploy_mode', { mode: 'framework', framework: detected.name }); @@ -1198,7 +1130,6 @@ async function runFrameworkDeploy( cliLog?.info('package_start', { mode: 'framework', framework: detected.name, - legacyUpload: legacyUpload === true, }); const packageStartedAt = Date.now(); const neededSet = skipped > 0 ? new Set(needed) : null; @@ -1209,85 +1140,48 @@ async function runFrameworkDeploy( compatibilityFlags: wranglerCompat.compatibilityFlags, }; - let result: StreamDeployResult; - if (legacyUpload) { - const formData = await packageFrameworkBuild({ - frameworkName: detected.name, - workerDir: actualWorkerDir, - workerMain: actualWorkerMain, - assetsDir, - bindings, - migrations: validatedMigrations, - schedules, - queues, - revalidate, - prerender, - assetConfig: { not_found_handling: 'none' }, - vars: envVars, - packageOptions, - revalidateQueryAllowlist: voidConfig.routing?.revalidateQueryAllowlist, - neededAssets: neededSet, - hashedAssetsPrefix: fwPreset.hashedAssetsPrefix ?? 'assets', - headerRules, - redirectRules, - fallbackRules, - dialect, - assetIgnorePatterns: frameworkAssetIgnorePatterns, - onProgress, - assetFiles, - }); - formData.append('assetManifest', JSON.stringify(assetManifest)); - appendDeployMetadata(formData, gitMetadata); - cliLog?.info('package_end', { - mode: 'framework', - durationMs: Date.now() - packageStartedAt, - }); - result = await streamDeploy(client, config.projectId, formData, s, cliLog); - } else { - const manifest = buildFrameworkManifest({ - frameworkName: detected.name, - workerMain: actualWorkerMain, - bindings, - migrations: validatedMigrations, - schedules, - queues, - revalidate, - revalidateQueryAllowlist: voidConfig.routing?.revalidateQueryAllowlist, - prerender, - assetConfig: { not_found_handling: 'none' }, - vars: envVars, - packageOptions, - hashedAssetsPrefix: fwPreset.hashedAssetsPrefix ?? 'assets', - headerRules, - redirectRules, - fallbackRules, - dialect, - }); - const workerFiles = collectWorkerFiles(actualWorkerDir); - cliLog?.info('package_end', { - mode: 'framework', - durationMs: Date.now() - packageStartedAt, - }); + const manifest = buildFrameworkManifest({ + frameworkName: detected.name, + workerMain: actualWorkerMain, + bindings, + migrations: validatedMigrations, + schedules, + queues, + revalidate, + revalidateQueryAllowlist: voidConfig.routing?.revalidateQueryAllowlist, + prerender, + assetConfig: { not_found_handling: 'none' }, + vars: envVars, + packageOptions, + hashedAssetsPrefix: fwPreset.hashedAssetsPrefix ?? 'assets', + headerRules, + redirectRules, + fallbackRules, + dialect, + }); + const workerFiles = collectWorkerFiles(actualWorkerDir); + cliLog?.info('package_end', { + mode: 'framework', + durationMs: Date.now() - packageStartedAt, + }); - // Migration SQL bytes ride along inside the worker bundle (compiled in - // by the framework via `migration-handler.ts`); the deploy body only - // needs `manifest.migrations.pending` so the platform knows to invoke - // the worker's `/__void/migrate` endpoint after dispatch. So no special - // handling is needed for migrations on the direct-to-R2 path. - result = await streamDirectR2Deploy( - client, - config.projectId, - manifest, - assetManifest, - assetFiles, - needed, - gitMetadata, - neededSet !== null, - workerFiles, - s, - cliLog, - ); - } + // Migration SQL bytes ride along inside the worker bundle (compiled in + // by the framework via `migration-handler.ts`); the deploy body only + // needs `manifest.migrations.pending` so the platform knows to invoke + // the worker's `/__void/migrate` endpoint after dispatch. + const result = await streamDirectR2Deploy( + client, + config.projectId, + manifest, + assetManifest, + assetFiles, + needed, + gitMetadata, + neededSet !== null, + workerFiles, + s, + cliLog, + ); const summary = [ `${result.workers} worker module(s), ${formatKnownAssetSummary(result, skipped)}`, @@ -1329,7 +1223,6 @@ async function runFullDeploy( gitMetadata?: GitDeployMetadata, detected?: DetectedFramework, cliLog?: CliLogger, - legacyUpload?: boolean, ): Promise { cliLog?.info('deploy_mode', { mode: 'full', @@ -1468,7 +1361,7 @@ async function runFullDeploy( // Pre-deploy: ensure Hyperdrive is provisioned for PostgreSQL projects. // Migrations on disk also gate this — even if inference says needsD1: false, - // packageBuild will add the hyperdrive binding when migrations are present. + // buildVoidManifest will add the hyperdrive binding when migrations are present. if (dialect === 'postgresql' && (effectiveBindings.needsD1 || rawMigrations.length > 0)) { try { const resources = await client.getResources(config.projectId); @@ -1583,7 +1476,6 @@ async function runFullDeploy( fallbackRules, undefined, cliLog, - legacyUpload, ); } } @@ -1674,7 +1566,6 @@ async function runFullDeploy( hasWebSockets, authEnabled, framework: detected?.name, - legacyUpload: legacyUpload === true, }); const packageStartedAt = Date.now(); const neededSet = skipped > 0 ? new Set(needed) : null; @@ -1685,87 +1576,51 @@ async function runFullDeploy( revalidateQueryAllowlist: deployConfig.routing?.revalidateQueryAllowlist, }; - let result: StreamDeployResult; - if (legacyUpload) { - const formData = await packageBuild( - distDir, - workerDirName, - effectiveBindings, - validatedMigrations, - schedules, - isSsr || hasRoutes || hasMiddleware || hasWebSockets || authEnabled, - isFrameworkMode ? detected!.name : undefined, - revalidate, - envVars, - queues, - uniquePrerenderPaths, - assetConfig, - voidPackageOptions, - neededSet, - assetsPrefix, - headerRules, - redirectRules, - fallbackRules, - dialect, - webSockets, - onProgress, - assetFiles, - ); - formData.append('assetManifest', JSON.stringify(assetManifest)); - appendDeployMetadata(formData, gitMetadata); - cliLog?.info('package_end', { - mode: 'full', - durationMs: Date.now() - packageStartedAt, - }); - result = await streamDeploy(client, config.projectId, formData, s, cliLog); - } else { - // Surface the non-SPA-fallbacks mismatch (if any) before invoking the - // I/O-free manifest builder. Mirrors what `packageBuild` does on the - // legacy multipart path so both deploy modes warn identically. - const fallbackWarning = detectNonSpaFallbackWarning(assetConfig, fallbackRules); - if (fallbackWarning) { - onProgress?.(fallbackWarning); - console.warn(`[void] ${fallbackWarning}`); - } - - const manifest = buildVoidManifest({ - bindings: effectiveBindings, - migrations: validatedMigrations, - schedules, - ssr: isSsr || hasRoutes || hasMiddleware || hasWebSockets || authEnabled, - framework: isFrameworkMode ? detected!.name : undefined, - revalidate, - vars: envVars, - queues, - prerender: uniquePrerenderPaths, - assetConfig, - options: voidPackageOptions, - hashedAssetsPrefix: assetsPrefix, - headerRules, - redirectRules, - fallbackRules, - dialect, - webSockets, - }); - const workerFiles = collectWorkerFiles(join(distDir, workerDirName)); - cliLog?.info('package_end', { - mode: 'full', - durationMs: Date.now() - packageStartedAt, - }); - result = await streamDirectR2Deploy( - client, - config.projectId, - manifest, - assetManifest, - assetFiles, - needed, - gitMetadata, - neededSet !== null, - workerFiles, - s, - cliLog, - ); - } + // Surface the non-SPA-fallbacks mismatch (if any) before invoking the + // I/O-free manifest builder. + const fallbackWarning = detectNonSpaFallbackWarning(assetConfig, fallbackRules); + if (fallbackWarning) { + onProgress?.(fallbackWarning); + console.warn(`[void] ${fallbackWarning}`); + } + + const manifest = buildVoidManifest({ + bindings: effectiveBindings, + migrations: validatedMigrations, + schedules, + ssr: isSsr || hasRoutes || hasMiddleware || hasWebSockets || authEnabled, + framework: isFrameworkMode ? detected!.name : undefined, + revalidate, + vars: envVars, + queues, + prerender: uniquePrerenderPaths, + assetConfig, + options: voidPackageOptions, + hashedAssetsPrefix: assetsPrefix, + headerRules, + redirectRules, + fallbackRules, + dialect, + webSockets, + }); + const workerFiles = collectWorkerFiles(join(distDir, workerDirName)); + cliLog?.info('package_end', { + mode: 'full', + durationMs: Date.now() - packageStartedAt, + }); + const result = await streamDirectR2Deploy( + client, + config.projectId, + manifest, + assetManifest, + assetFiles, + needed, + gitMetadata, + neededSet !== null, + workerFiles, + s, + cliLog, + ); const summary = [ `${result.workers} worker module(s), ${formatKnownAssetSummary(result, skipped)}`, diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 0e95194e5..279541097 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -47,11 +47,6 @@ const HELP_TREE: HelpTree = { command: '--debug', description: 'Mirror the deploy log to stderr (always written to ~/.void/logs)', }, - { - command: '--legacy-upload', - description: - 'Use the legacy multipart upload path. For backward compatibility only; will be removed in a future release.', - }, ], examples: [ { command: 'void deploy', description: 'Deploy the linked project' }, diff --git a/packages/void/src/cli/package.ts b/packages/void/src/cli/package.ts index 8cfa6886a..624c9b1f4 100644 --- a/packages/void/src/cli/package.ts +++ b/packages/void/src/cli/package.ts @@ -370,32 +370,6 @@ export async function collectAndHashAssets( return { assetManifest, assetFiles }; } -/** - * Append asset files to FormData, filtered by the set of needed paths. - * If neededPaths is null, all assets are included (no preflight). - */ -export function appendAssets( - formData: FormData, - assetFiles: Map, - neededPaths: Set | null, -): void { - for (const [path, content] of assetFiles) { - if (neededPaths && !neededPaths.has(path)) { - continue; - } - const key = path.startsWith('/') ? path.slice(1) : path; - formData.append(`asset:${key}`, new Blob([new Uint8Array(content)]), key); - } -} - -/** - * Package the build output into a FormData ready for upload. - * - * Build output structure: - * dist/ - * / ← worker output (index.js + chunks) - * client/ ← static SPA assets - */ /** Optional overrides for binding names and worker metadata. */ export interface PackageOptions { /** Custom binding names (e.g. { d1: "MY_DB" }). Defaults: DB, KV, STORAGE. */ @@ -467,11 +441,7 @@ export function detectNonSpaFallbackWarning( /** * Build a Void-app deploy manifest from the inferred bindings + per-feature - * inputs. Pure shape transformation: reads no files, has no I/O. Both the - * legacy multipart packager (`packageBuild`) and the direct-to-R2 bundler - * (`prepareVoidDeployBundle`) call into this so the manifest is computed - * once per deploy and the wire shape stays bit-for-bit identical between - * the two paths. + * inputs. Pure shape transformation: reads no files, has no I/O. * * Note: callers are responsible for surfacing the non-SPA-fallbacks warning * (see `detectNonSpaFallbackWarning`) before invoking this builder. The @@ -674,161 +644,8 @@ export function collectWorkerFiles(workerDir: string): Map { return out; } -export async function packageBuild( - distDir: string, - workerDirName: string, - bindings: InferredBindings, - migrations?: Array, - schedules?: Array, - ssr?: boolean, - framework?: string, - revalidate?: number | Record, - vars?: Record, - queues?: Array, - prerender?: Array, - assetConfig?: AssetConfig, - options?: PackageOptions, - neededAssets?: Set | null, - hashedAssetsPrefix?: string, - headerRules?: Array, - redirectRules?: Array, - fallbackRules?: Array, - dialect?: 'sqlite' | 'postgresql', - webSockets?: Array, - onProgress?: ProgressCallback, - /** - * Pre-collected asset bytes from `collectAndHashAssets`. When provided, - * this function uses these buffers directly instead of re-reading files - * from disk. Eliminates the chain double-read between hashing and - * packaging (Codex Finding #2 in design doc 0068 review). - * - * When omitted, falls back to reading the client directory directly — - * this preserves backward compatibility for existing test callers. - */ - assetFiles?: Map, -): Promise { - const formData = new FormData(); - - // Surface the non-SPA-fallbacks mismatch (if any) here, before invoking - // the I/O-free manifest builder. Mirrors what `runFullDeploy` does on the - // direct-to-R2 path so both deploy modes warn identically. - const fallbackWarning = detectNonSpaFallbackWarning(assetConfig, fallbackRules); - if (fallbackWarning) { - onProgress?.(fallbackWarning); - console.warn(`[void] ${fallbackWarning}`); - } - - const manifest = buildVoidManifest({ - bindings, - migrations, - schedules, - ssr, - framework, - revalidate, - vars, - queues, - prerender, - assetConfig, - options, - hashedAssetsPrefix, - headerRules, - redirectRules, - fallbackRules, - dialect, - webSockets, - }); - - if (migrations && migrations.length > 0) { - onProgress?.( - `Packaging ${migrations.length} migration${migrations.length === 1 ? '' : 's'}...`, - ); - for (const m of migrations) { - formData.append(`migration:${m.name}`, new Blob([m.sql]), m.name); - } - } - - formData.append('manifest', JSON.stringify(manifest)); - - // Worker files (read once via the shared helper). - onProgress?.('Packaging worker files...'); - const workerDir = join(distDir, workerDirName); - for (const [relativePath, content] of collectWorkerFiles(workerDir)) { - formData.append(`worker:${relativePath}`, new Blob([new Uint8Array(content)]), relativePath); - } - - // Client assets — prefer the pre-collected buffer cache from - // `collectAndHashAssets`. Falls back to disk reads only when no cache - // was supplied (preserves legacy callers / tests). - if (assetFiles) { - appendAssetsFromCache(formData, assetFiles, neededAssets ?? null); - } else { - const clientDir = join(distDir, 'client'); - try { - const { isIgnored, assetsIgnoreFilePresent } = createAssetsIgnoreFunction(clientDir); - const clientFiles = collectFiles(clientDir); - let processed = 0; - for (const filePath of clientFiles) { - processed++; - await tickProgress(processed, clientFiles.length, onProgress, 'Packaging assets'); - const relativePath = toSlash(relative(clientDir, filePath)); - if (isIgnored('/' + relativePath)) { - continue; - } - checkWorkerJsAsset(relativePath, assetsIgnoreFilePresent); - if (neededAssets && !neededAssets.has('/' + relativePath)) { - continue; - } - const content = readFileSync(filePath); - formData.append(`asset:${relativePath}`, new Blob([content]), relativePath); - } - } catch (err) { - if ( - err instanceof Error && - 'code' in err && - (err as NodeJS.ErrnoException).code === 'ENOENT' - ) { - // No client directory — API-only project - } else { - throw err; - } - } - } - - if (neededAssets) { - formData.append('preflight', 'true'); - } - - return formData; -} - -/** - * Append asset entries to FormData from the in-memory buffer cache. Mirrors - * the legacy disk-read loop but consumes pre-hashed bytes — the same buffers - * `collectAndHashAssets` computed BLAKE3+MD5 over — so the deploy chain - * reads each asset file from disk exactly once end-to-end. - * - * `_worker.js` safety guards already ran in `collectAndHashAssets` (which - * walks the same directory with the same ignore filter), so callers that - * feed buffers from there don't need to re-check here. - */ -function appendAssetsFromCache( - formData: FormData, - assetFiles: Map, - neededAssets: Set | null, -): void { - for (const [absPath, content] of assetFiles) { - if (neededAssets && !neededAssets.has(absPath)) { - continue; - } - const key = absPath.startsWith('/') ? absPath.slice(1) : absPath; - formData.append(`asset:${key}`, new Blob([new Uint8Array(content)]), key); - } -} - /** * Build a static-deploy manifest. Pure shape transformation — no I/O. - * Shared between the legacy multipart packager (`packageStaticBuild`) and - * the direct-to-R2 bundler (`prepareStaticDeployBundle`). */ export function buildStaticManifest(opts: { appType: 'spa' | 'static'; @@ -870,78 +687,8 @@ export function buildStaticManifest(opts: { return manifest; } -/** - * Package a static build (no worker) into a FormData ready for upload. - */ -export async function packageStaticBuild( - outputDir: string, - appType: 'spa' | 'static', - neededAssets?: Set | null, - hashedAssetsPrefix?: string, - headerRules?: Array, - redirectRules?: Array, - fallbackRules?: Array, - onProgress?: ProgressCallback, - /** - * Pre-collected asset bytes from `collectAndHashAssets`. When provided, - * skips the directory walk and re-reads — eliminates the chain - * double-read flagged in design doc 0068 review. - */ - assetFiles?: Map, -): Promise { - const formData = new FormData(); - - const manifest = buildStaticManifest({ - appType, - hashedAssetsPrefix, - headerRules, - redirectRules, - fallbackRules, - }); - - formData.append('manifest', JSON.stringify(manifest)); - - if (assetFiles) { - appendAssetsFromCache(formData, assetFiles, neededAssets ?? null); - } else { - const { isIgnored, assetsIgnoreFilePresent } = createAssetsIgnoreFunction(outputDir); - const files = collectFiles(outputDir); - let processed = 0; - for (const filePath of files) { - processed++; - await tickProgress(processed, files.length, onProgress, 'Packaging assets'); - const relativePath = toSlash(relative(outputDir, filePath)); - if (isIgnored('/' + relativePath)) { - continue; - } - checkWorkerJsAsset(relativePath, assetsIgnoreFilePresent); - if (neededAssets && !neededAssets.has('/' + relativePath)) { - continue; - } - const content = readFileSync(filePath); - formData.append(`asset:${relativePath}`, new Blob([content]), relativePath); - } - } - - if (neededAssets) { - formData.append('preflight', 'true'); - } - - return formData; -} - -/** - * Package a framework build (Class B/C) into a FormData ready for upload. - * - * Unlike `packageBuild`, the worker and assets directories are absolute paths - * (resolved from the project root + preset), and the worker directory may be - * inside the assets directory (SvelteKit, Astro). Worker files are excluded - * from asset collection when directories overlap. - */ /** * Build a framework-deploy manifest. Pure shape transformation — no I/O. - * Shared between the legacy multipart packager (`packageFrameworkBuild`) - * and the direct-to-R2 bundler (`prepareFrameworkDeployBundle`). */ export function buildFrameworkManifest(opts: { frameworkName: string; @@ -1048,112 +795,6 @@ export function buildFrameworkManifest(opts: { return manifest; } -export async function packageFrameworkBuild(opts: { - frameworkName: string; - workerDir: string; - workerMain: string; - assetsDir: string; - bindings: InferredBindings; - migrations?: Array; - schedules?: Array; - queues?: Array; - revalidate?: number | Record; - revalidateQueryAllowlist?: Record>; - prerender?: Array; - assetConfig?: AssetConfig; - vars?: Record; - packageOptions?: PackageOptions; - neededAssets?: Set | null; - hashedAssetsPrefix?: string; - headerRules?: Array; - redirectRules?: Array; - fallbackRules?: Array; - dialect?: 'sqlite' | 'postgresql'; - webSockets?: Array; - assetIgnorePatterns?: Array; - onProgress?: ProgressCallback; - /** - * Pre-collected asset bytes from `collectAndHashAssets`. When provided, - * skips the assets directory walk and re-reads — eliminates the chain - * double-read flagged in design doc 0068 review. - */ - assetFiles?: Map; -}): Promise { - const formData = new FormData(); - - const manifest = buildFrameworkManifest(opts); - - if (opts.migrations && opts.migrations.length > 0) { - opts.onProgress?.( - `Packaging ${opts.migrations.length} migration${opts.migrations.length === 1 ? '' : 's'}...`, - ); - for (const m of opts.migrations) { - formData.append(`migration:${m.name}`, new Blob([m.sql]), m.name); - } - } - - formData.append('manifest', JSON.stringify(manifest)); - - // Worker files (read once via the shared helper). - opts.onProgress?.('Packaging worker files...'); - for (const [relativePath, content] of collectWorkerFiles(opts.workerDir)) { - formData.append(`worker:${relativePath}`, new Blob([new Uint8Array(content)]), relativePath); - } - - // Collect worker files and asset files, handling directory overlap - const workerDirNorm = opts.workerDir.replace(/\\/g, '/'); - const assetsDirNorm = opts.assetsDir.replace(/\\/g, '/'); - const isOverlap = - workerDirNorm.startsWith(assetsDirNorm + '/') || workerDirNorm === assetsDirNorm; - - if (opts.assetFiles) { - appendAssetsFromCache(formData, opts.assetFiles, opts.neededAssets ?? null); - } else { - try { - const { isIgnored, assetsIgnoreFilePresent } = createAssetsIgnoreFunction( - opts.assetsDir, - opts.assetIgnorePatterns, - ); - const diskFiles = collectFiles(opts.assetsDir); - let processed = 0; - for (const filePath of diskFiles) { - processed++; - await tickProgress(processed, diskFiles.length, opts.onProgress, 'Packaging assets'); - // Skip worker files when workerDir overlaps with assetsDir - if (isOverlap && filePath.replace(/\\/g, '/').startsWith(workerDirNorm)) { - continue; - } - const relativePath = toSlash(relative(opts.assetsDir, filePath)); - if (isIgnored('/' + relativePath)) { - continue; - } - checkWorkerJsAsset(relativePath, assetsIgnoreFilePresent); - if (opts.neededAssets && !opts.neededAssets.has('/' + relativePath)) { - continue; - } - const content = readFileSync(filePath); - formData.append(`asset:${relativePath}`, new Blob([content]), relativePath); - } - } catch (err) { - if ( - err instanceof Error && - 'code' in err && - (err as NodeJS.ErrnoException).code === 'ENOENT' - ) { - // No assets directory - } else { - throw err; - } - } - } - - if (opts.neededAssets) { - formData.append('preflight', 'true'); - } - - return formData; -} - /** * Check if a file (by relative path) should be included as a worker module. * Skips build metadata and files with extensions CF Workers can't handle as modules. diff --git a/packages/void/test/integration/direct-r2-deploy.test.ts b/packages/void/test/integration/direct-r2-deploy.test.ts index 244fa52cb..9945932ad 100644 --- a/packages/void/test/integration/direct-r2-deploy.test.ts +++ b/packages/void/test/integration/direct-r2-deploy.test.ts @@ -99,7 +99,6 @@ interface MockState { uploadUrlsCalls: Array; r2PutCalls: Array<{ url: string; init: RequestInit }>; finalizeCalls: Array<{ url: string; init: RequestInit }>; - multipartDeployCalls: Array<{ url: string; init: RequestInit }>; } function installPlatformMocks(opts?: { @@ -113,7 +112,6 @@ function installPlatformMocks(opts?: { uploadUrlsCalls: [], r2PutCalls: [], finalizeCalls: [], - multipartDeployCalls: [], }; fetchMock.mockImplementation(async (input: RequestInfo, init?: RequestInit) => { @@ -163,14 +161,8 @@ function installPlatformMocks(opts?: { } if (url.endsWith('/deploy') && method === 'POST') { - const ct = (reqInit.headers as Record | undefined)?.['Content-Type']; - if (ct === 'application/json') { - state.finalizeCalls.push({ url, init: reqInit }); - return ndjsonResponse(opts?.finalizeEvents ?? defaultDoneEvents()); - } - // Multipart path (legacy). - state.multipartDeployCalls.push({ url, init: reqInit }); - return ndjsonResponse(defaultDoneEvents()); + state.finalizeCalls.push({ url, init: reqInit }); + return ndjsonResponse(opts?.finalizeEvents ?? defaultDoneEvents()); } throw new Error(`unexpected fetch: ${method} ${url}`); @@ -244,7 +236,6 @@ describe('direct-to-R2 deploy flow', () => { undefined, undefined, undefined, // cliLog (tested separately) - false, // legacyUpload = false → direct-to-R2 ); // Preflight + upload-urls + R2 PUTs + finalize were called once each. @@ -252,7 +243,6 @@ describe('direct-to-R2 deploy flow', () => { expect(state.uploadUrlsCalls).toHaveLength(1); expect(state.r2PutCalls).toHaveLength(4); expect(state.finalizeCalls).toHaveLength(1); - expect(state.multipartDeployCalls).toHaveLength(0); // Each R2 PUT carries Content-Md5 and the asset bytes. for (const { init } of state.r2PutCalls) { @@ -299,7 +289,6 @@ describe('direct-to-R2 deploy flow', () => { undefined, undefined, undefined, - false, ); // Filter to just the fixture files — `.assetsignore` lookup may fire @@ -352,7 +341,6 @@ describe('direct-to-R2 deploy flow', () => { undefined, undefined, cliLog, - false, ); await cliLog.close(); @@ -390,7 +378,6 @@ describe('direct-to-R2 deploy flow', () => { undefined, undefined, cliLog, - false, ); await cliLog.close(); @@ -418,7 +405,6 @@ describe('direct-to-R2 deploy flow', () => { undefined, undefined, undefined, - false, ); expect(state.uploadUrlsCalls).toHaveLength(0); @@ -458,7 +444,6 @@ describe('direct-to-R2 deploy flow', () => { undefined, undefined, undefined, - false, ); // 4 assets but the first one was retried 2 times, so total PUTs = 4 + 2 = 6. @@ -488,81 +473,10 @@ describe('direct-to-R2 deploy flow', () => { undefined, undefined, undefined, - false, ), ).rejects.toThrow(/R2 PUT failed.+SignatureMismatch/); }); - it('uses legacy multipart path when legacyUpload=true', async () => { - const state = installPlatformMocks(); - const client = makeClient(); - - await runStaticDeploy( - workdir, - { projectId: 'proj_t', slug: 'test' }, - client, - { buildCommand: null, outputDir: assetsDir, appType: 'spa' }, - true, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - true, // legacyUpload = true - ); - - // Direct path NOT used. - expect(state.uploadUrlsCalls).toHaveLength(0); - expect(state.r2PutCalls).toHaveLength(0); - expect(state.finalizeCalls).toHaveLength(0); - - // Multipart was used. - expect(state.multipartDeployCalls).toHaveLength(1); - const headers = state.multipartDeployCalls[0].init.headers as Record; - // The fetch body is FormData → CT not set explicitly, fetch handles it. - // Just verify the multipart path was hit. - expect(headers['Content-Type']).toBeUndefined(); - expect(state.multipartDeployCalls[0].init.body).toBeInstanceOf(FormData); - }); - - it('reads each asset file from disk EXACTLY ONCE in legacy multipart path too', async () => { - installPlatformMocks(); - const client = makeClient(); - readFileSyncMock.mockClear(); - - await runStaticDeploy( - workdir, - { projectId: 'proj_t', slug: 'test' }, - client, - { buildCommand: null, outputDir: assetsDir, appType: 'spa' }, - true, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - true, - ); - - const reads = readFileSyncMock.mock.calls.filter(([p]: Array) => { - const path = String(p); - return ( - path.endsWith('index.html') || - path.endsWith('style.css') || - path.endsWith('script.js') || - path.endsWith('large.bin') - ); - }); - - // The legacy path also threads buffers through via `assetFiles` so the - // double-read is eliminated end-to-end. - expect(reads).toHaveLength(4); - }); - it('writes presign + r2_put + finalize events to the JSONL log', async () => { installPlatformMocks(); const cliLog = openDeployLog(); @@ -584,7 +498,6 @@ describe('direct-to-R2 deploy flow', () => { undefined, undefined, cliLog, - false, ); await cliLog.close(); @@ -633,7 +546,6 @@ describe('direct-to-R2 deploy flow', () => { undefined, undefined, undefined, - false, ); // 6 paths total but only 5 unique blake3 values (about.html == @@ -686,7 +598,6 @@ describe('direct-to-R2 deploy flow', () => { undefined, undefined, cliLog, - false, ), ).rejects.toThrow(new RegExp(escapeRegex(logPath as string))); diff --git a/packages/void/test/unit/migration-binding-consistency.test.ts b/packages/void/test/unit/migration-binding-consistency.test.ts index ea50e7bf9..297df3bd1 100644 --- a/packages/void/test/unit/migration-binding-consistency.test.ts +++ b/packages/void/test/unit/migration-binding-consistency.test.ts @@ -1,8 +1,7 @@ import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'pathe'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { packageBuild, packageFrameworkBuild } from '../../src/cli/package.ts'; -import type { DeployManifest } from '../../src/cli/types.ts'; +import { buildVoidManifest, buildFrameworkManifest } from '../../src/cli/package.ts'; import { inferBindingsFromSource, mergeBindings } from '../../src/inference/walk.ts'; import type { InferredBindings } from '../../src/inference/walk.ts'; import { collectMigrations } from '../../src/migrations/collect.ts'; @@ -33,27 +32,6 @@ const sampleMigrations: Array = [ }, ]; -function setupWorkerDir() { - const workerDir = join(TMP, 'worker'); - mkdirSync(workerDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - return workerDir; -} - -function getManifest(form: FormData): DeployManifest { - return JSON.parse(form.get('manifest') as string); -} - -function getMigrationEntries(form: FormData): Array { - const keys: Array = []; - for (const [key] of form.entries()) { - if (key.startsWith('migration:')) { - keys.push(key.slice('migration:'.length)); - } - } - return keys; -} - beforeEach(() => { mkdirSync(TMP, { recursive: true }); }); @@ -63,81 +41,63 @@ afterEach(() => { }); // --------------------------------------------------------------------------- -// packageBuild: migration ↔ binding consistency +// buildVoidManifest: migration ↔ binding consistency // --------------------------------------------------------------------------- -describe('packageBuild migration-binding consistency', () => { - it('includes both d1 binding and migrations when needsD1 is true', async () => { - setupWorkerDir(); - const form = await packageBuild( - TMP, - 'worker', - { ...noBindings, needsD1: true }, - sampleMigrations, - ); - const manifest = getManifest(form); +describe('buildVoidManifest migration-binding consistency', () => { + it('includes both d1 binding and migrations when needsD1 is true', () => { + const manifest = buildVoidManifest({ + bindings: { ...noBindings, needsD1: true }, + migrations: sampleMigrations, + }); expect(manifest.bindings.d1).toEqual(['DB']); expect(manifest.migrations).toEqual({ dialect: 'sqlite', pending: 1 }); - expect(getMigrationEntries(form)).toEqual(['0001_create_users.sql']); }); - it('forces d1 binding when migrations exist even if needsD1 is false', async () => { - setupWorkerDir(); - const form = await packageBuild(TMP, 'worker', noBindings, sampleMigrations); - const manifest = getManifest(form); + it('forces d1 binding when migrations exist even if needsD1 is false', () => { + const manifest = buildVoidManifest({ + bindings: noBindings, + migrations: sampleMigrations, + }); // Migrations imply a database is needed — d1 binding must be present expect(manifest.bindings.d1).toEqual(['DB']); expect(manifest.migrations).toEqual({ dialect: 'sqlite', pending: 1 }); - expect(getMigrationEntries(form)).toEqual(['0001_create_users.sql']); }); - it('has no migrations and no d1 binding when both are absent', async () => { - setupWorkerDir(); - const form = await packageBuild(TMP, 'worker', noBindings); - const manifest = getManifest(form); + it('has no migrations and no d1 binding when both are absent', () => { + const manifest = buildVoidManifest({ bindings: noBindings }); expect(manifest.bindings.d1).toBeUndefined(); expect(manifest.migrations).toBeUndefined(); - expect(getMigrationEntries(form)).toEqual([]); }); - it('has no migrations when migrations array is empty', async () => { - setupWorkerDir(); - const form = await packageBuild(TMP, 'worker', noBindings, []); - const manifest = getManifest(form); + it('has no migrations when migrations array is empty', () => { + const manifest = buildVoidManifest({ bindings: noBindings, migrations: [] }); expect(manifest.bindings.d1).toBeUndefined(); expect(manifest.migrations).toBeUndefined(); - expect(getMigrationEntries(form)).toEqual([]); }); - it('has d1 binding but no migrations when needsD1 is true with no migration files', async () => { - setupWorkerDir(); - const form = await packageBuild(TMP, 'worker', { ...noBindings, needsD1: true }); - const manifest = getManifest(form); + it('has d1 binding but no migrations when needsD1 is true with no migration files', () => { + const manifest = buildVoidManifest({ bindings: { ...noBindings, needsD1: true } }); expect(manifest.bindings.d1).toEqual(['DB']); expect(manifest.migrations).toBeUndefined(); }); - it('includes d1 binding when auth forces needsD1 even without explicit D1 usage', async () => { - setupWorkerDir(); - const form = await packageBuild( - TMP, - 'worker', - { ...noBindings, needsAuth: true }, - sampleMigrations, - ); - const manifest = getManifest(form); + it('includes d1 binding when auth forces needsD1 even without explicit D1 usage', () => { + const manifest = buildVoidManifest({ + bindings: { ...noBindings, needsAuth: true }, + migrations: sampleMigrations, + }); expect(manifest.bindings.d1).toEqual(['DB']); expect(manifest.migrations).toEqual({ dialect: 'sqlite', pending: 1 }); }); - it('forces d1 binding when multiple migrations exist without D1 usage', async () => { - setupWorkerDir(); + it('forces d1 binding when multiple migrations exist without D1 usage', () => { const migrations: Array = [ { name: '0001_create_users.sql', @@ -156,70 +116,34 @@ describe('packageBuild migration-binding consistency', () => { }, ]; - const form = await packageBuild(TMP, 'worker', noBindings, migrations); - const manifest = getManifest(form); + const manifest = buildVoidManifest({ bindings: noBindings, migrations }); expect(manifest.bindings.d1).toEqual(['DB']); expect(manifest.migrations).toEqual({ dialect: 'sqlite', pending: 3 }); - expect(getMigrationEntries(form).sort()).toEqual([ - '0001_create_users.sql', - '0002_create_posts.sql', - '0003_add_email.sql', - ]); }); - it('uses custom d1 binding name when provided', async () => { - setupWorkerDir(); - const form = await packageBuild( - TMP, - 'worker', - { ...noBindings, needsD1: true }, - sampleMigrations, - /* schedules */ undefined, - /* ssr */ undefined, - /* framework */ undefined, - /* revalidate */ undefined, - /* vars */ undefined, - /* queues */ undefined, - /* prerender */ undefined, - /* assetConfig */ undefined, - /* options */ { bindingNames: { d1: 'MY_DB' } }, - ); - const manifest = getManifest(form); + it('uses custom d1 binding name when provided', () => { + const manifest = buildVoidManifest({ + bindings: { ...noBindings, needsD1: true }, + migrations: sampleMigrations, + options: { bindingNames: { d1: 'MY_DB' } }, + }); expect(manifest.bindings.d1).toEqual(['MY_DB']); }); }); // --------------------------------------------------------------------------- -// packageBuild: postgresql / hyperdrive variant +// buildVoidManifest: postgresql / hyperdrive variant // --------------------------------------------------------------------------- -describe('packageBuild postgresql migration-binding consistency', () => { - it('forces hyperdrive binding when migrations exist with postgresql dialect and needsD1 is false', async () => { - setupWorkerDir(); - const form = await packageBuild( - TMP, - 'worker', - noBindings, - sampleMigrations, - /* schedules */ undefined, - /* ssr */ undefined, - /* framework */ undefined, - /* revalidate */ undefined, - /* vars */ undefined, - /* queues */ undefined, - /* prerender */ undefined, - /* assetConfig */ undefined, - /* options */ undefined, - /* neededAssets */ undefined, - /* hashedAssetsPrefix */ undefined, - /* headerRules */ undefined, - /* redirectRules */ undefined, - /* fallbackRules */ undefined, - /* dialect */ 'postgresql', - ); - const manifest = getManifest(form); +describe('buildVoidManifest postgresql migration-binding consistency', () => { + it('forces hyperdrive binding when migrations exist with postgresql dialect and needsD1 is false', () => { + const manifest = buildVoidManifest({ + bindings: noBindings, + migrations: sampleMigrations, + dialect: 'postgresql', + }); // Migrations with postgresql dialect imply hyperdrive is needed expect(manifest.bindings.hyperdrive).toEqual(['HYPERDRIVE']); @@ -227,30 +151,12 @@ describe('packageBuild postgresql migration-binding consistency', () => { expect(manifest.migrations).toEqual({ dialect: 'postgresql', pending: 1 }); }); - it('includes both hyperdrive binding and migrations when needsD1 is true with postgresql dialect', async () => { - setupWorkerDir(); - const form = await packageBuild( - TMP, - 'worker', - { ...noBindings, needsD1: true }, - sampleMigrations, - /* schedules */ undefined, - /* ssr */ undefined, - /* framework */ undefined, - /* revalidate */ undefined, - /* vars */ undefined, - /* queues */ undefined, - /* prerender */ undefined, - /* assetConfig */ undefined, - /* options */ undefined, - /* neededAssets */ undefined, - /* hashedAssetsPrefix */ undefined, - /* headerRules */ undefined, - /* redirectRules */ undefined, - /* fallbackRules */ undefined, - /* dialect */ 'postgresql', - ); - const manifest = getManifest(form); + it('includes both hyperdrive binding and migrations when needsD1 is true with postgresql dialect', () => { + const manifest = buildVoidManifest({ + bindings: { ...noBindings, needsD1: true }, + migrations: sampleMigrations, + dialect: 'postgresql', + }); expect(manifest.bindings.hyperdrive).toEqual(['HYPERDRIVE']); expect(manifest.bindings.d1).toBeUndefined(); @@ -259,81 +165,54 @@ describe('packageBuild postgresql migration-binding consistency', () => { }); // --------------------------------------------------------------------------- -// packageFrameworkBuild: same consistency requirement +// buildFrameworkManifest: same consistency requirement // --------------------------------------------------------------------------- -describe('packageFrameworkBuild migration-binding consistency', () => { - function setupFrameworkDirs() { - const workerDir = join(TMP, 'server'); - const assetsDir = join(TMP, 'public'); - mkdirSync(workerDir, { recursive: true }); - mkdirSync(assetsDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - writeFileSync(join(assetsDir, 'index.html'), ''); - return { workerDir, assetsDir }; - } - - it('forces d1 binding when migrations exist even if needsD1 is false', async () => { - const { workerDir, assetsDir } = setupFrameworkDirs(); - const form = await packageFrameworkBuild({ +describe('buildFrameworkManifest migration-binding consistency', () => { + it('forces d1 binding when migrations exist even if needsD1 is false', () => { + const manifest = buildFrameworkManifest({ frameworkName: 'nuxt', - workerDir, workerMain: 'index.js', - assetsDir, bindings: noBindings, migrations: sampleMigrations, }); - const manifest = getManifest(form); expect(manifest.bindings.d1).toEqual(['DB']); expect(manifest.migrations).toEqual({ dialect: 'sqlite', pending: 1 }); - expect(getMigrationEntries(form)).toEqual(['0001_create_users.sql']); }); - it('includes both d1 binding and migrations when needsD1 is true', async () => { - const { workerDir, assetsDir } = setupFrameworkDirs(); - const form = await packageFrameworkBuild({ + it('includes both d1 binding and migrations when needsD1 is true', () => { + const manifest = buildFrameworkManifest({ frameworkName: 'nuxt', - workerDir, workerMain: 'index.js', - assetsDir, bindings: { ...noBindings, needsD1: true }, migrations: sampleMigrations, }); - const manifest = getManifest(form); expect(manifest.bindings.d1).toEqual(['DB']); expect(manifest.migrations).toEqual({ dialect: 'sqlite', pending: 1 }); }); - it('includes d1 binding via auth even without explicit D1 usage', async () => { - const { workerDir, assetsDir } = setupFrameworkDirs(); - const form = await packageFrameworkBuild({ + it('includes d1 binding via auth even without explicit D1 usage', () => { + const manifest = buildFrameworkManifest({ frameworkName: 'nuxt', - workerDir, workerMain: 'index.js', - assetsDir, bindings: { ...noBindings, needsAuth: true }, migrations: sampleMigrations, }); - const manifest = getManifest(form); expect(manifest.bindings.d1).toEqual(['DB']); expect(manifest.migrations).toEqual({ dialect: 'sqlite', pending: 1 }); }); - it('forces hyperdrive binding when postgresql migrations exist with needsD1 false', async () => { - const { workerDir, assetsDir } = setupFrameworkDirs(); - const form = await packageFrameworkBuild({ + it('forces hyperdrive binding when postgresql migrations exist with needsD1 false', () => { + const manifest = buildFrameworkManifest({ frameworkName: 'nuxt', - workerDir, workerMain: 'index.js', - assetsDir, bindings: noBindings, migrations: sampleMigrations, dialect: 'postgresql', }); - const manifest = getManifest(form); expect(manifest.bindings.hyperdrive).toEqual(['HYPERDRIVE']); expect(manifest.bindings.d1).toBeUndefined(); @@ -421,11 +300,11 @@ describe('createMigrationHandler with missing D1 binding', () => { }); // --------------------------------------------------------------------------- -// End-to-end: collectMigrations + inference + packageBuild +// End-to-end: collectMigrations + inference + buildVoidManifest // --------------------------------------------------------------------------- describe('end-to-end: D1 removed but migrations remain on disk', () => { - it('forces d1 binding when user removes D1 usage but keeps migration files', async () => { + it('forces d1 binding when user removes D1 usage but keeps migration files', () => { // Source code has no void/db imports or env.DB usage const sourceCode = `export const GET = defineHandler(() => ({ message: "hello" }));`; const bindings = inferBindingsFromSource(sourceCode, 'hello.ts'); @@ -442,20 +321,13 @@ describe('end-to-end: D1 removed but migrations remain on disk', () => { expect(rawMigrations).toHaveLength(1); const validatedMigrations = validateMigrations(rawMigrations); - // Package build should force d1 binding because migrations exist - const workerDir = join(TMP, 'dist', 'worker'); - mkdirSync(workerDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - const distDir = join(TMP, 'dist'); - - const form = await packageBuild(distDir, 'worker', bindings, validatedMigrations); - const manifest = getManifest(form); + const manifest = buildVoidManifest({ bindings, migrations: validatedMigrations }); expect(manifest.bindings.d1).toEqual(['DB']); expect(manifest.migrations).toEqual({ dialect: 'sqlite', pending: 1 }); }); - it('works correctly when D1 usage is still present', async () => { + it('works correctly when D1 usage is still present', () => { const sourceCode = ` import { db } from "void/db"; export const GET = defineHandler(async () => { @@ -473,19 +345,13 @@ describe('end-to-end: D1 removed but migrations remain on disk', () => { ); const validatedMigrations = validateMigrations(collectMigrations(migrationsDir)); - const workerDir = join(TMP, 'dist', 'worker'); - mkdirSync(workerDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - const distDir = join(TMP, 'dist'); - - const form = await packageBuild(distDir, 'worker', bindings, validatedMigrations); - const manifest = getManifest(form); + const manifest = buildVoidManifest({ bindings, migrations: validatedMigrations }); expect(manifest.bindings.d1).toEqual(['DB']); expect(manifest.migrations).toEqual({ dialect: 'sqlite', pending: 1 }); }); - it('produces clean manifest when both D1 and migrations are removed', async () => { + it('produces clean manifest when both D1 and migrations are removed', () => { const sourceCode = `export const GET = defineHandler(() => ({ ok: true }));`; const bindings = inferBindingsFromSource(sourceCode, 'health.ts'); @@ -493,19 +359,13 @@ describe('end-to-end: D1 removed but migrations remain on disk', () => { const rawMigrations = collectMigrations(migrationsDir); expect(rawMigrations).toHaveLength(0); - const workerDir = join(TMP, 'dist', 'worker'); - mkdirSync(workerDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - const distDir = join(TMP, 'dist'); - - const form = await packageBuild(distDir, 'worker', bindings); - const manifest = getManifest(form); + const manifest = buildVoidManifest({ bindings }); expect(manifest.migrations).toBeUndefined(); expect(manifest.bindings.d1).toBeUndefined(); }); - it('forces d1 binding when multiple old migrations remain after removing env.DB', async () => { + it('forces d1 binding when multiple old migrations remain after removing env.DB', () => { const sourceCode = ` export const GET = defineHandler(async (c) => { const data = await fetch("https://api.example.com/users"); @@ -527,19 +387,13 @@ describe('end-to-end: D1 removed but migrations remain on disk', () => { ); const validatedMigrations = validateMigrations(collectMigrations(migrationsDir)); - const workerDir = join(TMP, 'dist', 'worker'); - mkdirSync(workerDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - const distDir = join(TMP, 'dist'); - - const form = await packageBuild(distDir, 'worker', bindings, validatedMigrations); - const manifest = getManifest(form); + const manifest = buildVoidManifest({ bindings, migrations: validatedMigrations }); expect(manifest.bindings.d1).toEqual(['DB']); expect(manifest.migrations).toEqual({ dialect: 'sqlite', pending: 2 }); }); - it('auth-enabled project with leftover migrations stays consistent', async () => { + it('auth-enabled project with leftover migrations stays consistent', () => { const authSource = `import { auth } from "void/client"; await auth.signOut();`; const routeSource = `export const GET = defineHandler(() => ({ ok: true }));`; const merged = mergeBindings([ @@ -561,19 +415,16 @@ describe('end-to-end: D1 removed but migrations remain on disk', () => { ); const validatedMigrations = validateMigrations(collectMigrations(migrationsDir)); - const workerDir = join(TMP, 'dist', 'worker'); - mkdirSync(workerDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - const distDir = join(TMP, 'dist'); - - const form = await packageBuild(distDir, 'worker', effectiveBindings, validatedMigrations); - const manifest = getManifest(form); + const manifest = buildVoidManifest({ + bindings: effectiveBindings, + migrations: validatedMigrations, + }); expect(manifest.bindings.d1).toEqual(['DB']); expect(manifest.migrations!.pending).toBe(1); }); - it('forces d1 binding for non-auth project with leftover migrations', async () => { + it('forces d1 binding for non-auth project with leftover migrations', () => { const routeSource = `export const GET = defineHandler(() => ({ ok: true }));`; const bindings = inferBindingsFromSource(routeSource, 'health.ts'); expect(bindings.needsD1).toBe(false); @@ -587,13 +438,7 @@ describe('end-to-end: D1 removed but migrations remain on disk', () => { ); const validatedMigrations = validateMigrations(collectMigrations(migrationsDir)); - const workerDir = join(TMP, 'dist', 'worker'); - mkdirSync(workerDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - const distDir = join(TMP, 'dist'); - - const form = await packageBuild(distDir, 'worker', bindings, validatedMigrations); - const manifest = getManifest(form); + const manifest = buildVoidManifest({ bindings, migrations: validatedMigrations }); // Migrations should force the d1 binding even without auth or D1 imports expect(manifest.bindings.d1).toEqual(['DB']); diff --git a/packages/void/test/unit/package.test.ts b/packages/void/test/unit/package.test.ts index 3c3079983..7e40a601c 100644 --- a/packages/void/test/unit/package.test.ts +++ b/packages/void/test/unit/package.test.ts @@ -1,11 +1,8 @@ import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { hash as blake3hash } from 'blake3-jit'; import { join } from 'pathe'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { - packageBuild, - packageStaticBuild, - packageFrameworkBuild, computeAssetHash, collectAndHashAssets, resolveSpaFallbackChain, @@ -21,118 +18,6 @@ afterEach(() => { rmSync(TMP, { recursive: true, force: true }); }); -describe('packageStaticBuild', () => { - it('includes all assets in form data', async () => { - writeFileSync(join(TMP, 'a.html'), 'a'); - writeFileSync(join(TMP, 'b.js'), 'b'); - - const form = await packageStaticBuild(TMP, 'spa'); - - const assetKeys: Array = []; - for (const [key] of form.entries()) { - if (key.startsWith('asset:')) { - assetKeys.push(key.slice('asset:'.length)); - } - } - - expect(assetKeys.sort()).toEqual(['a.html', 'b.js']); - }); - - it('sets version 2 in manifest', async () => { - writeFileSync(join(TMP, 'index.html'), 'x'); - - const form = await packageStaticBuild(TMP, 'spa'); - - const manifestRaw = form.get('manifest') as string; - const manifest = JSON.parse(manifestRaw); - expect(manifest.version).toBe(3); - }); - - it('sets assetConfig for SPA mode', async () => { - writeFileSync(join(TMP, 'index.html'), 'x'); - - const form = await packageStaticBuild(TMP, 'spa'); - - const manifestRaw = form.get('manifest') as string; - const manifest = JSON.parse(manifestRaw); - expect(manifest.assetConfig).toEqual({ - not_found_handling: 'single-page-application', - }); - }); - - it('sets assetConfig for static mode', async () => { - writeFileSync(join(TMP, 'index.html'), 'x'); - - const form = await packageStaticBuild(TMP, 'static'); - - const manifestRaw = form.get('manifest') as string; - const manifest = JSON.parse(manifestRaw); - expect(manifest.assetConfig).toEqual({ - not_found_handling: '404-page', - }); - }); - - it('keeps platform-level SPA fallback when no routing.fallbacks are configured', async () => { - writeFileSync(join(TMP, 'index.html'), 'x'); - - const form = await packageStaticBuild(TMP, 'spa'); - - const manifest = JSON.parse(form.get('manifest') as string); - expect(manifest.assetConfig).toEqual({ - not_found_handling: 'single-page-application', - }); - expect(manifest.fallbackRules).toBeUndefined(); - }); - - it('disables platform SPA switch and appends synthetic /* -> /index.html after user fallbacks', async () => { - writeFileSync(join(TMP, 'index.html'), 'x'); - - const userFallbacks = [ - { source: '/docs/*', destination: '/docs.html', status: 200, force: false }, - ]; - const form = await packageStaticBuild( - TMP, - 'spa', - undefined, - undefined, - undefined, - undefined, - userFallbacks, - ); - - const manifest = JSON.parse(form.get('manifest') as string); - expect(manifest.assetConfig).toEqual({ not_found_handling: 'none' }); - // First-match-wins: user rule comes first, synthetic catch-all last. - expect(manifest.fallbackRules).toEqual([ - { source: '/docs/*', destination: '/docs.html', status: 200 }, - { source: '/*', destination: '/index.html', status: 200 }, - ]); - }); - - it('passes through user fallbacks unchanged for non-SPA static app type', async () => { - writeFileSync(join(TMP, 'index.html'), 'x'); - - const userFallbacks = [ - { source: '/docs/*', destination: '/docs.html', status: 200, force: false }, - ]; - const form = await packageStaticBuild( - TMP, - 'static', - undefined, - undefined, - undefined, - undefined, - userFallbacks, - ); - - const manifest = JSON.parse(form.get('manifest') as string); - expect(manifest.assetConfig).toEqual({ not_found_handling: '404-page' }); - expect(manifest.fallbackRules).toEqual([ - { source: '/docs/*', destination: '/docs.html', status: 200 }, - ]); - }); -}); - describe('resolveSpaFallbackChain', () => { it('returns SPA platform switch with no rules when user has no fallbacks (SPA)', () => { const result = resolveSpaFallbackChain('spa', undefined); @@ -218,450 +103,6 @@ describe('resolveSpaFallbackChain', () => { }); }); -describe('packageBuild', () => { - it('excludes .vite/ directory from worker files', async () => { - // Set up dist/worker/ with worker files and .vite/manifest.json - const workerDir = join(TMP, 'worker'); - mkdirSync(join(workerDir, '.vite'), { recursive: true }); - mkdirSync(join(workerDir, 'assets'), { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - writeFileSync(join(workerDir, 'assets', 'chunk.js'), 'var x = 1'); - writeFileSync(join(workerDir, '.vite', 'manifest.json'), '{"key":"value"}'); - - const form = await packageBuild(TMP, 'worker', { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: false, - needsQueues: false, - needsAI: false, - needsSandbox: false, - }); - - const workerKeys: Array = []; - for (const [key] of form.entries()) { - if (key.startsWith('worker:')) { - workerKeys.push(key.slice('worker:'.length)); - } - } - - expect(workerKeys.sort()).toEqual(['assets/chunk.js', 'index.js']); - expect(workerKeys).not.toContain('.vite/manifest.json'); - }); - - it('sets version 2 in manifest', async () => { - const workerDir = join(TMP, 'worker'); - mkdirSync(workerDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - - const form = await packageBuild(TMP, 'worker', { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: false, - needsQueues: false, - needsAI: false, - needsSandbox: false, - }); - - const manifestRaw = form.get('manifest') as string; - const manifest = JSON.parse(manifestRaw); - expect(manifest.version).toBe(3); - }); - - it('includes revalidateQueryAllowlist in worker manifests', async () => { - const workerDir = join(TMP, 'worker'); - mkdirSync(workerDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - - const form = await packageBuild( - TMP, - 'worker', - { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: false, - needsQueues: false, - needsAI: false, - needsSandbox: false, - }, - undefined, - undefined, - true, - undefined, - 60, - undefined, - undefined, - undefined, - undefined, - { revalidateQueryAllowlist: { '/search': ['q'] } }, - ); - - const manifest = JSON.parse(form.get('manifest') as string); - expect(manifest.revalidateQueryAllowlist).toEqual({ '/search': ['q'] }); - }); - - it('marks auth-enabled deploys in the manifest', async () => { - const workerDir = join(TMP, 'worker'); - mkdirSync(workerDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - - const form = await packageBuild(TMP, 'worker', { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: true, - needsQueues: false, - needsAI: false, - needsSandbox: false, - }); - - const manifestRaw = form.get('manifest') as string; - const manifest = JSON.parse(manifestRaw); - expect(manifest.auth).toBe(true); - }); - - it('layers user fallbacks with SPA default when not_found_handling is single-page-application', async () => { - const workerDir = join(TMP, 'worker'); - mkdirSync(workerDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - - const userFallbacks = [ - { source: '/docs/*', destination: '/docs.html', status: 200, force: false }, - ]; - const form = await packageBuild( - TMP, - 'worker', - { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: false, - needsQueues: false, - needsAI: false, - needsSandbox: false, - }, - undefined, // migrations - undefined, // schedules - undefined, // ssr - undefined, // framework - undefined, // revalidate - undefined, // vars - undefined, // queues - undefined, // prerender - { not_found_handling: 'single-page-application' }, - undefined, // options - undefined, // neededAssets - undefined, // hashedAssetsPrefix - undefined, // headerRules - undefined, // redirectRules - userFallbacks, - ); - - const manifest = JSON.parse(form.get('manifest') as string); - expect(manifest.assetConfig).toEqual({ not_found_handling: 'none' }); - // First-match-wins: user rule first, synthetic catch-all last. - expect(manifest.fallbackRules).toEqual([ - { source: '/docs/*', destination: '/docs.html', status: 200 }, - { source: '/*', destination: '/index.html', status: 200 }, - ]); - }); - - it('warns when routing.fallbacks is set on a non-SPA (SSR) packageBuild', async () => { - // On SSR / Void / framework apps, user fallbacks still fire post-lookup, - // but no `/* -> /index.html` SPA shell is synthesised. Surface the - // mismatch at build time so users don't silently ship fallbacks that - // won't save them from the platform 404. - const workerDir = join(TMP, 'worker'); - mkdirSync(workerDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - - const userFallbacks = [ - { source: '/docs/*', destination: '/docs.html', status: 200, force: false }, - ]; - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await packageBuild( - TMP, - 'worker', - { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: false, - needsQueues: false, - needsAI: false, - needsSandbox: false, - }, - undefined, // migrations - undefined, // schedules - true, // ssr - undefined, // framework - undefined, // revalidate - undefined, // vars - undefined, // queues - undefined, // prerender - undefined, // assetConfig — no SPA signal, so the SSR branch fires - undefined, // options - undefined, // neededAssets - undefined, // hashedAssetsPrefix - undefined, // headerRules - undefined, // redirectRules - userFallbacks, - ); - - const messages = warnSpy.mock.calls.map((c) => String(c[0])); - expect( - messages.some( - (m) => - m.includes('routing.fallbacks is set but appType is not "spa"') && - m.includes('no "/* -> /index.html" SPA shell fallback is synthesised'), - ), - ).toBe(true); - warnSpy.mockRestore(); - }); - - it('does not warn about the SPA shell when fallbacks are paired with the SPA asset config', async () => { - // Regression anchor: the SSR warning must only fire when - // `not_found_handling !== 'single-page-application'`. On SPA the - // resolveSpaFallbackChain path runs instead (and has its own warnings). - const workerDir = join(TMP, 'worker'); - mkdirSync(workerDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - - const userFallbacks = [ - { source: '/docs/*', destination: '/docs.html', status: 200, force: false }, - ]; - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await packageBuild( - TMP, - 'worker', - { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: false, - needsQueues: false, - needsAI: false, - needsSandbox: false, - }, - undefined, // migrations - undefined, // schedules - undefined, // ssr - undefined, // framework - undefined, // revalidate - undefined, // vars - undefined, // queues - undefined, // prerender - { not_found_handling: 'single-page-application' }, - undefined, // options - undefined, // neededAssets - undefined, // hashedAssetsPrefix - undefined, // headerRules - undefined, // redirectRules - userFallbacks, - ); - - const messages = warnSpy.mock.calls.map((c) => String(c[0])); - expect(messages.some((m) => m.includes('appType is not "spa"'))).toBe(false); - warnSpy.mockRestore(); - }); - - it('leaves SPA platform switch in place when no user fallbacks exist', async () => { - const workerDir = join(TMP, 'worker'); - mkdirSync(workerDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - - const form = await packageBuild( - TMP, - 'worker', - { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: false, - needsQueues: false, - needsAI: false, - needsSandbox: false, - }, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { not_found_handling: 'single-page-application' }, - ); - - const manifest = JSON.parse(form.get('manifest') as string); - expect(manifest.assetConfig).toEqual({ not_found_handling: 'single-page-application' }); - expect(manifest.fallbackRules).toBeUndefined(); - }); - - it('includes sandbox metadata in the manifest', async () => { - const workerDir = join(TMP, 'worker'); - mkdirSync(workerDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - - const form = await packageBuild( - TMP, - 'worker', - { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: false, - needsQueues: false, - needsAI: false, - needsSandbox: true, - }, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { - sandbox: { - binding: 'SANDBOX', - className: 'Sandbox', - containerName: 'void-sandbox', - image: '/tmp/Dockerfile', - platformImage: 'docker.io/cloudflare/sandbox:0.8.11', - }, - }, - ); - - const manifestRaw = form.get('manifest') as string; - const manifest = JSON.parse(manifestRaw); - expect(manifest.sandbox).toEqual({ - bindingName: 'SANDBOX', - className: 'Sandbox', - containerName: 'void-sandbox', - image: 'docker.io/cloudflare/sandbox:0.8.11', - }); - }); -}); - -describe('packageFrameworkBuild', () => { - it('includes revalidateQueryAllowlist in framework manifests', async () => { - const workerDir = join(TMP, 'server'); - const assetsDir = join(TMP, 'public'); - mkdirSync(workerDir, { recursive: true }); - mkdirSync(assetsDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - writeFileSync(join(assetsDir, 'index.html'), ''); - - const form = await packageFrameworkBuild({ - frameworkName: 'test', - workerDir, - workerMain: 'index.js', - assetsDir, - bindings: { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: false, - needsQueues: false, - needsAI: false, - needsSandbox: false, - }, - revalidate: 60, - revalidateQueryAllowlist: { '/search': ['q'] }, - }); - - const manifest = JSON.parse(form.get('manifest') as string); - expect(manifest.revalidateQueryAllowlist).toEqual({ '/search': ['q'] }); - }); - - it('strips dev-only origin and force metadata from redirectRules and fallbackRules in the manifest', async () => { - const workerDir = join(TMP, 'server'); - const assetsDir = join(TMP, 'public'); - mkdirSync(workerDir, { recursive: true }); - mkdirSync(assetsDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - writeFileSync(join(assetsDir, 'index.html'), ''); - - const redirectRules = [ - { - source: '/old', - destination: '/new', - status: 301, - force: false, - origin: '_redirects:12', - }, - { - source: '/api/old', - destination: '/api/new', - status: 200, - force: true, - origin: 'void.json#routing.rewrites', - }, - ]; - const fallbackRules = [ - { - source: '/*', - destination: '/index.html', - status: 200, - force: false, - origin: 'spa-default', - }, - { - source: '/docs/*', - destination: '/docs.html', - status: 200, - force: false, - origin: 'void.json#routing.fallbacks', - }, - ]; - - const form = await packageFrameworkBuild({ - frameworkName: 'test', - workerDir, - workerMain: 'index.js', - assetsDir, - bindings: { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: false, - needsQueues: false, - needsAI: false, - needsSandbox: false, - }, - redirectRules, - fallbackRules, - }); - - const manifest = JSON.parse(form.get('manifest') as string); - - expect(manifest.redirectRules).toEqual([ - { source: '/old', destination: '/new', status: 301 }, - { source: '/api/old', destination: '/api/new', status: 200 }, - ]); - for (const rule of manifest.redirectRules) { - expect(rule).not.toHaveProperty('origin'); - expect(rule).not.toHaveProperty('force'); - } - - expect(manifest.fallbackRules).toEqual([ - { source: '/*', destination: '/index.html', status: 200 }, - { source: '/docs/*', destination: '/docs.html', status: 200 }, - ]); - for (const rule of manifest.fallbackRules) { - expect(rule).not.toHaveProperty('origin'); - expect(rule).not.toHaveProperty('force'); - } - }); -}); - describe('computeAssetHash', () => { it('returns a 32-char hex string', () => { const hash = computeAssetHash(Buffer.from('hello world'), 'test.html'); @@ -778,70 +219,12 @@ describe('collectAndHashAssets', () => { }); }); -describe('packageStaticBuild with neededAssets', () => { - it('only includes needed assets when set is provided', async () => { - writeFileSync(join(TMP, 'a.html'), 'a'); - writeFileSync(join(TMP, 'b.js'), 'b'); - writeFileSync(join(TMP, 'c.css'), 'c'); - - const needed = new Set(['/a.html', '/c.css']); - const form = await packageStaticBuild(TMP, 'spa', needed); - - const assetKeys: Array = []; - for (const [key] of form.entries()) { - if (key.startsWith('asset:')) { - assetKeys.push(key.slice('asset:'.length)); - } - } - - expect(assetKeys.sort()).toEqual(['a.html', 'c.css']); - expect(form.get('preflight')).toBe('true'); - }); - - it('includes all assets when neededAssets is null', async () => { - writeFileSync(join(TMP, 'a.html'), 'a'); - writeFileSync(join(TMP, 'b.js'), 'b'); - - const form = await packageStaticBuild(TMP, 'spa', null); - - const assetKeys: Array = []; - for (const [key] of form.entries()) { - if (key.startsWith('asset:')) { - assetKeys.push(key.slice('asset:'.length)); - } - } - - expect(assetKeys.sort()).toEqual(['a.html', 'b.js']); - expect(form.get('preflight')).toBeNull(); - }); -}); - -describe('packageStaticBuild asset exclusion', () => { - it('excludes _headers and _redirects from packaged assets', async () => { - writeFileSync(join(TMP, 'index.html'), ''); - writeFileSync(join(TMP, 'app.js'), 'console.log()'); - writeFileSync(join(TMP, '_headers'), '/* X-Test: 1'); - writeFileSync(join(TMP, '_redirects'), '/old /new 301'); - - const form = await packageStaticBuild(TMP, 'static'); - - const assetKeys: Array = []; - for (const [key] of form.entries()) { - if (key.startsWith('asset:')) { - assetKeys.push(key.slice('asset:'.length)); - } - } - - expect(assetKeys.sort()).toEqual(['app.js', 'index.html']); - }); -}); - -describe('_worker.js safety guard', () => { +describe('_worker.js safety guard (collectAndHashAssets)', () => { it('throws when _worker.js file exists without .assetsignore', async () => { writeFileSync(join(TMP, 'index.html'), ''); writeFileSync(join(TMP, '_worker.js'), 'export default {}'); - await expect(packageStaticBuild(TMP, 'static')).rejects.toThrow( + await expect(collectAndHashAssets(TMP)).rejects.toThrow( 'deploy: Refusing to upload _worker.js file as an asset', ); }); @@ -851,7 +234,7 @@ describe('_worker.js safety guard', () => { writeFileSync(join(TMP, 'index.html'), ''); writeFileSync(join(TMP, '_worker.js', 'index.js'), 'export default {}'); - await expect(packageStaticBuild(TMP, 'static')).rejects.toThrow( + await expect(collectAndHashAssets(TMP)).rejects.toThrow( 'deploy: Refusing to upload _worker.js directory as an asset', ); }); @@ -861,16 +244,9 @@ describe('_worker.js safety guard', () => { writeFileSync(join(TMP, '_worker.js'), 'export default {}'); writeFileSync(join(TMP, '.assetsignore'), ''); - // Does not throw — .assetsignore presence means the user is aware - const form = await packageStaticBuild(TMP, 'static'); - const assetKeys: Array = []; - for (const [key] of form.entries()) { - if (key.startsWith('asset:')) { - assetKeys.push(key.slice('asset:'.length)); - } - } + const { assetManifest } = await collectAndHashAssets(TMP); // _worker.js is included because .assetsignore doesn't exclude it - expect(assetKeys).toContain('_worker.js'); + expect(Object.keys(assetManifest).sort()).toEqual(['/_worker.js', '/index.html']); }); it('excludes _worker.js when .assetsignore lists it', async () => { @@ -878,24 +254,8 @@ describe('_worker.js safety guard', () => { writeFileSync(join(TMP, '_worker.js'), 'export default {}'); writeFileSync(join(TMP, '.assetsignore'), '_worker.js'); - const form = await packageStaticBuild(TMP, 'static'); - const assetKeys: Array = []; - for (const [key] of form.entries()) { - if (key.startsWith('asset:')) { - assetKeys.push(key.slice('asset:'.length)); - } - } - expect(assetKeys).not.toContain('_worker.js'); - expect(assetKeys).toContain('index.html'); - }); - - it('guards collectAndHashAssets against _worker.js too', async () => { - writeFileSync(join(TMP, 'index.html'), ''); - writeFileSync(join(TMP, '_worker.js'), 'export default {}'); - - await expect(collectAndHashAssets(TMP)).rejects.toThrow( - 'deploy: Refusing to upload _worker.js file as an asset', - ); + const { assetManifest } = await collectAndHashAssets(TMP); + expect(Object.keys(assetManifest)).toEqual(['/index.html']); }); it('lets framework deploys ignore co-located worker control files during preflight', async () => { @@ -909,92 +269,4 @@ describe('_worker.js safety guard', () => { expect(Object.keys(assetManifest).sort()).toEqual(['/index.html']); }); - - it('guards packageBuild client assets against _worker.js', async () => { - // Set up worker dir so packageBuild doesn't fail - const workerDir = join(TMP, 'worker'); - mkdirSync(workerDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - // Put _worker.js in client dir - const clientDir = join(TMP, 'client'); - mkdirSync(clientDir, { recursive: true }); - writeFileSync(join(clientDir, 'index.html'), ''); - writeFileSync(join(clientDir, '_worker.js'), 'export default {}'); - - await expect( - packageBuild(TMP, 'worker', { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: false, - needsQueues: false, - needsAI: false, - needsSandbox: false, - }), - ).rejects.toThrow('deploy: Refusing to upload _worker.js file as an asset'); - }); - - it('guards packageFrameworkBuild assets against _worker.js', async () => { - const workerDir = join(TMP, 'server'); - const assetsDir = join(TMP, 'public'); - mkdirSync(workerDir, { recursive: true }); - mkdirSync(assetsDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - writeFileSync(join(assetsDir, 'index.html'), ''); - writeFileSync(join(assetsDir, '_worker.js'), 'export default {}'); - - await expect( - packageFrameworkBuild({ - frameworkName: 'test', - workerDir, - workerMain: 'index.js', - assetsDir, - bindings: { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: false, - needsQueues: false, - needsAI: false, - needsSandbox: false, - }, - }), - ).rejects.toThrow('deploy: Refusing to upload _worker.js file as an asset'); - }); - - it('lets framework deploys ignore co-located worker control files during packaging', async () => { - const workerDir = join(TMP, 'server'); - const assetsDir = join(TMP, 'public'); - mkdirSync(workerDir, { recursive: true }); - mkdirSync(assetsDir, { recursive: true }); - writeFileSync(join(workerDir, 'index.js'), 'export default {}'); - writeFileSync(join(assetsDir, 'index.html'), ''); - writeFileSync(join(assetsDir, '_worker.js'), 'export default {}'); - writeFileSync(join(assetsDir, '_routes.json'), '{}'); - - const form = await packageFrameworkBuild({ - frameworkName: 'sveltekit', - workerDir, - workerMain: 'index.js', - assetsDir, - assetIgnorePatterns: ['/_worker.js', '/_routes.json'], - bindings: { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: false, - needsQueues: false, - needsAI: false, - needsSandbox: false, - }, - }); - - const assetKeys: Array = []; - for (const [key] of form.entries()) { - if (key.startsWith('asset:')) { - assetKeys.push(key.slice('asset:'.length)); - } - } - expect(assetKeys.sort()).toEqual(['index.html']); - }); });