Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/void/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down
49 changes: 42 additions & 7 deletions packages/void/src/cli/package.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand All @@ -40,12 +67,14 @@ export function collectAndHashAssets(dir: string): {
const assetFiles = new Map<string, Buffer>();

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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 5 additions & 8 deletions packages/void/test/integration/kitchen-sink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
106 changes: 95 additions & 11 deletions packages/void/test/unit/package.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
});
Expand All @@ -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"), "<html></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"), "<html></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", () => {
Expand Down Expand Up @@ -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"), "<html></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"]);
});
});
17 changes: 17 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions test/integration-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ export async function getPort(): Promise<number> {
return port;
}

export async function waitForServer(url: string, timeoutMs: number): Promise<void> {
export async function waitForServer(
url: string,
timeoutMs: number,
options?: { expectBody?: string },
): Promise<void> {
// 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);
Expand All @@ -41,7 +45,11 @@ export async function waitForServer(url: string, timeoutMs: number): Promise<voi
while (Date.now() - start < timeoutMs) {
try {
const response = await fetch(url);
if (response.status > 0) return;
if (response.status > 0) {
if (!options?.expectBody) return;
const body = await response.text();
if (body.includes(options.expectBody)) return;
}
} catch {
// retry
}
Expand Down
Loading