diff --git a/packages/void/package.json b/packages/void/package.json index 834b994ab..e12fd1356 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -174,12 +174,14 @@ "@napi-rs/keyring": "1.2.0", "better-auth": "^1.5.6", "better-sqlite3": "^12.8.0", + "blake3-jit": "^1.0.0", "drizzle-arktype": "^0.1.3", "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.45.1", "drizzle-valibot": "^0.4.2", "drizzle-zod": "^0.8.3", "hono": "^4.12.9", + "ignore": "^5.3.2", "jsonc-parser": "3.3.1", "pg": "^8.20.0", "wrangler": "catalog:" diff --git a/packages/void/src/cli/package.ts b/packages/void/src/cli/package.ts index 06f1c9bcf..778877c71 100644 --- a/packages/void/src/cli/package.ts +++ b/packages/void/src/cli/package.ts @@ -1,6 +1,7 @@ -import { createHash } from "node:crypto"; -import { readFileSync, readdirSync } from "node:fs"; -import { join, relative } from "node:path"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { extname, join, relative } from "node:path"; +import { hash as blake3hash } from "blake3-jit"; +import ignore from "ignore"; import type { InferredBindings } from "../inference/walk"; import type { MigrationFile } from "../migrations/validate"; import type { @@ -15,12 +16,38 @@ import type { import type { WebSocketRouteDefinition } from "../router/websocket"; import { toSlash } from "../shared/utils"; +const textEncoder = new TextEncoder(); + /** - * Compute a CF-compatible asset hash: SHA-256, first 32 hex chars. + * Compute a CF-compatible asset hash: BLAKE3 of base64(content) + extension, + * first 32 hex chars. Matches Wrangler's hashFile algorithm exactly. * Must match the API's computeAssetHash algorithm exactly. */ -export function computeAssetHash(content: Buffer): string { - return createHash("sha256").update(content).digest("hex").slice(0, 32); +export function computeAssetHash(content: Buffer, filePath: string): string { + const base64 = content.toString("base64"); + const ext = extname(filePath).substring(1); + const input = textEncoder.encode(base64 + ext); + return Buffer.from(blake3hash(input)).toString("hex").slice(0, 32); +} + +/** + * Create a filter function that excludes .assetsignore, _headers, _redirects, + * and any patterns listed in a .assetsignore file. Matches Wrangler's + * createAssetsIgnoreFunction from workers-shared/utils/helpers.ts. + */ +function createAssetsIgnoreFunction(dir: string): (relativePath: string) => boolean { + const patterns = ["/.assetsignore", "/_redirects", "/_headers"]; + const assetsIgnorePath = join(dir, ".assetsignore"); + if (existsSync(assetsIgnorePath)) { + const content = readFileSync(assetsIgnorePath, "utf-8"); + patterns.push(...content.split("\n")); + } + const ig = ignore().add(patterns); + // ignore library expects relative paths without leading / + return (relativePath: string) => { + const normalized = relativePath.startsWith("/") ? relativePath.slice(1) : relativePath; + return ig.test(normalized).ignored; + }; } export interface AssetManifestEntry { @@ -40,12 +67,14 @@ export function collectAndHashAssets(dir: string): { const assetFiles = new Map(); try { + const isIgnored = createAssetsIgnoreFunction(dir); const files = collectFiles(dir); for (const filePath of files) { const relativePath = "/" + toSlash(relative(dir, filePath)); + if (isIgnored(relativePath)) continue; const content = readFileSync(filePath); assetManifest[relativePath] = { - hash: computeAssetHash(content), + hash: computeAssetHash(content, filePath), size: content.length, }; assetFiles.set(relativePath, content); @@ -233,9 +262,11 @@ export function packageBuild( // Add client assets const clientDir = join(distDir, "client"); try { + const isIgnored = createAssetsIgnoreFunction(clientDir); const clientFiles = collectFiles(clientDir); for (const filePath of clientFiles) { const relativePath = toSlash(relative(clientDir, filePath)); + if (isIgnored("/" + relativePath)) continue; if (neededAssets && !neededAssets.has("/" + relativePath)) continue; const content = readFileSync(filePath); formData.append(`asset:${relativePath}`, new Blob([content]), relativePath); @@ -285,9 +316,11 @@ export function packageStaticBuild( formData.append("manifest", JSON.stringify(manifest)); + const isIgnored = createAssetsIgnoreFunction(outputDir); const files = collectFiles(outputDir); for (const filePath of files) { const relativePath = toSlash(relative(outputDir, filePath)); + if (isIgnored("/" + relativePath)) continue; if (neededAssets && !neededAssets.has("/" + relativePath)) continue; const content = readFileSync(filePath); formData.append(`asset:${relativePath}`, new Blob([content]), relativePath); @@ -405,11 +438,13 @@ export function packageFrameworkBuild(opts: { // Asset files try { + const isIgnored = createAssetsIgnoreFunction(opts.assetsDir); const assetFiles = collectFiles(opts.assetsDir); for (const filePath of assetFiles) { // 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; if (opts.neededAssets && !opts.neededAssets.has("/" + relativePath)) continue; const content = readFileSync(filePath); formData.append(`asset:${relativePath}`, new Blob([content]), relativePath); diff --git a/packages/void/test/integration/kitchen-sink.test.ts b/packages/void/test/integration/kitchen-sink.test.ts index 073e2777b..c2ba381ea 100644 --- a/packages/void/test/integration/kitchen-sink.test.ts +++ b/packages/void/test/integration/kitchen-sink.test.ts @@ -63,14 +63,11 @@ describe("playground/kitchen-sink", () => { stderr += String(chunk); }); - await waitForServer(BASE, 20_000); - - // Warm up: the first request triggers Vite SSR dep optimization - // (drizzle-orm, vue, hono, etc.) which causes multiple program - // reloads. Wait for the reloads to settle before running tests. - await fetch(BASE).catch(() => {}); - await new Promise((resolve) => setTimeout(resolve, 5_000)); - }, 40_000); + // The first request triggers Vite SSR dep optimization (drizzle-orm, + // vue, hono, etc.) which causes multiple program reloads. Wait until + // the server returns valid SSR HTML instead of sleeping a fixed time. + await waitForServer(BASE, 60_000, { expectBody: "Kitchen Sink" }); + }, 90_000); afterAll(async () => { if (child) await killChild(child); diff --git a/packages/void/test/unit/package.test.ts b/packages/void/test/unit/package.test.ts index 6a354acb5..a389214a1 100644 --- a/packages/void/test/unit/package.test.ts +++ b/packages/void/test/unit/package.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdirSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; +import { hash as blake3hash } from "blake3-jit"; import { packageBuild, packageStaticBuild, @@ -141,30 +142,69 @@ describe("packageBuild", () => { describe("computeAssetHash", () => { it("returns a 32-char hex string", () => { - const hash = computeAssetHash(Buffer.from("hello world")); + const hash = computeAssetHash(Buffer.from("hello world"), "test.html"); expect(hash).toHaveLength(32); expect(hash).toMatch(/^[0-9a-f]{32}$/); }); - it("returns consistent hashes for same content", () => { - const a = computeAssetHash(Buffer.from("test content")); - const b = computeAssetHash(Buffer.from("test content")); + it("returns consistent hashes for same content and path", () => { + const a = computeAssetHash(Buffer.from("test content"), "file.js"); + const b = computeAssetHash(Buffer.from("test content"), "file.js"); expect(a).toBe(b); }); it("returns different hashes for different content", () => { - const a = computeAssetHash(Buffer.from("content a")); - const b = computeAssetHash(Buffer.from("content b")); + const a = computeAssetHash(Buffer.from("content a"), "file.js"); + const b = computeAssetHash(Buffer.from("content b"), "file.js"); expect(a).not.toBe(b); }); - it("matches Web Crypto API output", async () => { + it("returns different hashes for same content with different extensions", () => { + const content = Buffer.from("identical bytes"); + const hashHtml = computeAssetHash(content, "file.html"); + const hashJs = computeAssetHash(content, "file.js"); + const hashNoExt = computeAssetHash(content, "file"); + expect(hashHtml).not.toBe(hashJs); + expect(hashHtml).not.toBe(hashNoExt); + expect(hashJs).not.toBe(hashNoExt); + }); + + it("matches Wrangler's BLAKE3 hash algorithm", () => { + // Wrangler computes: blake3(base64(content) + extension).hex().slice(0, 32) const content = Buffer.from("cross-compat test"); - const cliHash = computeAssetHash(content); + const filePath = "test.txt"; + const cliHash = computeAssetHash(content, filePath); - const digest = await crypto.subtle.digest("SHA-256", content); - const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); - const apiHash = hex.slice(0, 32); + // Reproduce Wrangler's algorithm directly + const base64 = content.toString("base64"); + const ext = "txt"; + const input = new TextEncoder().encode(base64 + ext); + const digest = blake3hash(input); + const wranglerHash = Buffer.from(digest).toString("hex").slice(0, 32); + + expect(cliHash).toBe(wranglerHash); + }); + + it("matches API-side hash for same input", () => { + // The API uses arrayBufferToBase64 + blake3-jit — verify cross-compatibility + const content = Buffer.from("api compat test"); + const filePath = "styles.css"; + const cliHash = computeAssetHash(content, filePath); + + // Simulate API-side: ArrayBuffer → base64 via btoa(String.fromCharCode) + const bytes = new Uint8Array(content); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + const apiBase64 = btoa(binary); + const ext = "css"; + const input = new TextEncoder().encode(apiBase64 + ext); + const digest = blake3hash(input); + const apiHash = [...digest] + .slice(0, 16) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); expect(cliHash).toBe(apiHash); }); @@ -190,6 +230,30 @@ describe("collectAndHashAssets", () => { expect(Object.keys(assetManifest)).toHaveLength(0); expect(assetFiles.size).toBe(0); }); + + it("excludes _headers, _redirects, and .assetsignore from manifest", () => { + writeFileSync(join(TMP, "index.html"), ""); + writeFileSync(join(TMP, "_headers"), "/* X-Frame-Options: DENY"); + writeFileSync(join(TMP, "_redirects"), "/old /new 301"); + writeFileSync(join(TMP, ".assetsignore"), ""); + + const { assetManifest, assetFiles } = collectAndHashAssets(TMP); + + expect(Object.keys(assetManifest)).toEqual(["/index.html"]); + expect(assetFiles.size).toBe(1); + }); + + it("honors custom .assetsignore patterns", () => { + mkdirSync(join(TMP, "secret"), { recursive: true }); + writeFileSync(join(TMP, "index.html"), ""); + writeFileSync(join(TMP, "draft.txt"), "draft"); + writeFileSync(join(TMP, "secret", "key.pem"), "secret"); + writeFileSync(join(TMP, ".assetsignore"), "*.txt\nsecret/"); + + const { assetManifest } = collectAndHashAssets(TMP); + + expect(Object.keys(assetManifest)).toEqual(["/index.html"]); + }); }); describe("packageStaticBuild with neededAssets", () => { @@ -229,3 +293,23 @@ describe("packageStaticBuild with neededAssets", () => { expect(form.get("preflight")).toBeNull(); }); }); + +describe("packageStaticBuild asset exclusion", () => { + it("excludes _headers and _redirects from packaged assets", () => { + 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 = packageStaticBuild(TMP, "static"); + + const assetKeys: string[] = []; + for (const [key] of form.entries()) { + if (key.startsWith("asset:")) { + assetKeys.push(key.slice("asset:".length)); + } + } + + expect(assetKeys.sort()).toEqual(["app.js", "index.html"]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50f167191..19fea6bca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,6 +253,9 @@ importers: better-sqlite3: specifier: ^12.8.0 version: 12.8.0 + blake3-jit: + specifier: ^1.0.0 + version: 1.0.0 drizzle-arktype: specifier: ^0.1.3 version: 0.1.3(arktype@2.2.0)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.2)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.8.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2))(typescript@6.0.2))(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.8.0)(kysely@0.28.14)(mysql2@3.15.3)(pg@8.20.0)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@12.8.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2))) @@ -271,6 +274,9 @@ importers: hono: specifier: ^4.12.9 version: 4.12.9 + ignore: + specifier: ^5.3.2 + version: 5.3.2 jsonc-parser: specifier: 3.3.1 version: 3.3.1 @@ -4411,6 +4417,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + blake3-jit@1.0.0: + resolution: {integrity: sha512-0Ulza5lYejLqATjvLKlNlsEbDGclbN4bbPzAImemeApmnihTf9ERQOQg018hpKw2xbrweT/1WEZ0OEeFkvjhuQ==} + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} @@ -5450,6 +5459,10 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + ignore@7.0.5: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} @@ -11565,6 +11578,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + blake3-jit@1.0.0: {} + blake3-wasm@2.1.5: {} boolbase@1.0.0: {} @@ -12583,6 +12598,8 @@ snapshots: ieee754@1.2.1: {} + ignore@5.3.2: {} + ignore@7.0.5: {} image-meta@0.2.2: {} diff --git a/test/integration-helpers.ts b/test/integration-helpers.ts index d61b51a1e..705d24ddd 100644 --- a/test/integration-helpers.ts +++ b/test/integration-helpers.ts @@ -26,7 +26,11 @@ export async function getPort(): Promise { return port; } -export async function waitForServer(url: string, timeoutMs: number): Promise { +export async function waitForServer( + url: string, + timeoutMs: number, + options?: { expectBody?: string }, +): Promise { // Release the held port so the child process can bind to it. // This is called right after spawn(), minimizing the TOCTOU window. const urlPort = Number(new URL(url).port); @@ -41,7 +45,11 @@ export async function waitForServer(url: string, timeoutMs: number): Promise 0) return; + if (response.status > 0) { + if (!options?.expectBody) return; + const body = await response.text(); + if (body.includes(options.expectBody)) return; + } } catch { // retry }