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: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ See [`packages/solid/README.md`](./packages/solid/README.md) for details.
| -------- | --------- | ---------------------------------------------------------------------------------------------- |
| Node.js | 24.13.1 | Runtime (TS type stripping enabled natively — no `tsx` or `--experimental-strip-types` needed) |
| Vite+ | 0.1.x | Unified toolchain (`vp`, Vite, Vitest, Oxlint, Oxfmt, tsdown) |
| pnpm | 10.x | Package manager used by Vite+ for this workspace |
| pnpm | 11.x | Package manager used by Vite+ for this workspace |
| tsdown | via Vite+ | Library packaging through `vp pack` |
| wrangler | 4.x | CF Worker dev/deploy |
| vitest | via Vite+ | Unit + integration tests via `vp test` |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@
"vite-plus": "catalog:",
"vitest": "catalog:"
},
"packageManager": "pnpm@10.33.2"
"packageManager": "pnpm@11.0.8"
}
137 changes: 98 additions & 39 deletions packages/void/src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ const EMPTY_PROJECT_ALLOWED_ENTRIES = new Set([
'.zencoder',
]);

const PNPM_ONLY_BUILT_DEPENDENCIES = ['better-sqlite3', 'esbuild', 'workerd'] as const;
const PNPM_ALLOWED_BUILDS = ['better-sqlite3', 'esbuild', 'workerd'] as const;
const drizzleKitBin = join(fileURLToPath(import.meta.resolve('drizzle-kit')), '..', 'bin.cjs');

const EMPTY_PROJECT_ALLOWED_PACKAGES = new Set([
Expand Down Expand Up @@ -1801,23 +1801,97 @@ function ensureGitignore(root: string): void {
log.success('Updated .gitignore with Void defaults');
}

function ensurePnpmBuiltDependencies(pkg: JsonObject): void {
const pnpm = asRecord(pkg.pnpm) ?? {};
pkg.pnpm = pnpm;

const onlyBuiltDependencies = Array.isArray(pnpm.onlyBuiltDependencies)
? pnpm.onlyBuiltDependencies.filter(
(value: unknown): value is string => typeof value === 'string',
)
: [];
function removeTopLevelYamlBlock(yaml: string, key: string): string {
const lines = yaml.split(/\r?\n/);
const next: Array<string> = [];
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (!new RegExp(`^${key}:\\s*(?:#.*)?$`).test(line)) {
next.push(line);
continue;
}

for (const dependency of PNPM_ONLY_BUILT_DEPENDENCIES) {
if (!onlyBuiltDependencies.includes(dependency)) {
onlyBuiltDependencies.push(dependency);
i += 1;
while (i < lines.length && (lines[i].trim() === '' || /^\s/.test(lines[i]))) {
i += 1;
}
i -= 1;
}
return next.join('\n');
}

function pnpmAllowBuildsYaml(): string {
const lines = ['allowBuilds:'];
for (const dependency of PNPM_ALLOWED_BUILDS) {
lines.push(` ${dependency}: true`);
}
return lines.join('\n');
}

function insertPnpmAllowBuilds(yaml: string): string {
const trimmed = yaml.trim();
const allowBuilds = pnpmAllowBuildsYaml();
if (trimmed === '') {
return `${allowBuilds}\n`;
}

const lines = trimmed.split(/\r?\n/);
const packagesIndex = lines.findIndex((line) => /^packages:\s*(?:#.*)?$/.test(line));
if (packagesIndex === -1) {
return `${trimmed}\n\n${allowBuilds}\n`;
}

let insertIndex = packagesIndex + 1;
while (
insertIndex < lines.length &&
(lines[insertIndex].trim() === '' || /^\s/.test(lines[insertIndex]))
) {
insertIndex += 1;
}
lines.splice(insertIndex, 0, '', ...allowBuilds.split('\n'), '');
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n')}\n`;
}

function resolvePnpmConfigRoot(root: string, pkg: JsonObject): string {
if (
existsSync(join(root, 'pnpm-workspace.yaml')) ||
existsSync(join(root, 'pnpm-lock.yaml')) ||
parsePackageManagerField(pkg) === 'pnpm'
) {
return root;
}

pnpm.onlyBuiltDependencies = onlyBuiltDependencies;
const workspaceRoot = findWorkspaceRoot(root);
if (
workspaceRoot !== undefined &&
isWorkspaceSubpackage(workspaceRoot, root) &&
hasAncestorPnpmMarkers(root)
) {
return workspaceRoot;
}

return root;
}

function ensurePnpmAllowBuilds(root: string, pkg: JsonObject): boolean {
const configRoot = resolvePnpmConfigRoot(root, pkg);
const workspacePath = join(configRoot, 'pnpm-workspace.yaml');
const before = existsSync(workspacePath) ? readFileSync(workspacePath, 'utf-8') : '';
const withoutLegacy = removeTopLevelYamlBlock(
removeTopLevelYamlBlock(before, 'onlyBuiltDependencies'),
'allowBuilds',
);
const after = insertPnpmAllowBuilds(withoutLegacy);
if (after !== before) {
writeFileSync(workspacePath, after);
log.success(
configRoot === root
? 'Updated pnpm-workspace.yaml with approved builds'
: `Updated ${relative(root, workspacePath)} with approved builds`,
);
return true;
}
return false;
}

function installProjectDependencies(
Expand Down Expand Up @@ -2065,16 +2139,16 @@ function scaffoldPagesApp(
ensureDependency('devDependencies', name, version);
}

// Round 44: `ensurePnpmBuiltDependencies` must run for pnpm workspace
// Round 44: the pnpm build approval setup must run for workspace
// subpackages too. `detectProjectState` only checks LOCAL markers and
// returns `'npm'` for a subpackage without its own lockfile / pin,
// but `isPnpmProject(root, pkg)` walks ancestors and (after round 43)
// verifies membership. Gate the allowlist on the ancestor-aware check
// so the subpackage gets `pnpm.onlyBuiltDependencies` and its
// so the workspace gets `allowBuilds` and its
// better-sqlite3 / esbuild / workerd postinstalls run on the next
// workspace-root `pnpm install`.
if (isPnpmProject(root, pkg)) {
ensurePnpmBuiltDependencies(pkg);
ensurePnpmAllowBuilds(root, pkg);
}
// The Corepack pin is ONLY for the standalone pnpm case. In a
// workspace subpackage the pin lives on the workspace root's
Expand All @@ -2084,7 +2158,7 @@ function scaffoldPagesApp(
packageManager === 'pnpm' &&
(!pkg.packageManager || typeof pkg.packageManager !== 'string')
) {
pkg.packageManager = 'pnpm@10.33.0';
pkg.packageManager = 'pnpm@11.0.8';
}

writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n');
Expand Down Expand Up @@ -2188,14 +2262,7 @@ function ensureExistingProjectPackage(
dependencyAdded = true;
}

if (isPnpmProject(root, pkg)) {
const before = JSON.stringify(asRecord(pkg.pnpm)?.onlyBuiltDependencies ?? null);
ensurePnpmBuiltDependencies(pkg);
const after = JSON.stringify(asRecord(pkg.pnpm)?.onlyBuiltDependencies ?? null);
if (before !== after) {
changed = true;
}
}
const pnpmConfigChanged = isPnpmProject(root, pkg) ? ensurePnpmAllowBuilds(root, pkg) : false;

if (changed) {
writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n');
Expand All @@ -2210,7 +2277,7 @@ function ensureExistingProjectPackage(
);
}

return { dependencyAdded, packageJsonMutated: changed };
return { dependencyAdded, packageJsonMutated: changed || pnpmConfigChanged };
}

function insertVoidImport(code: string): string {
Expand Down Expand Up @@ -2855,9 +2922,8 @@ export function ensureVoidDependency(root: string): boolean {
changed = true;
}

// Mirror `scaffoldPagesApp`'s pnpm built-dep allowlist. pnpm 10 ignores
// build scripts unless the package is listed in
// `pnpm.onlyBuiltDependencies`; without this, the freshly scaffolded
// Mirror `scaffoldPagesApp`'s pnpm build approval config. pnpm requires
// build scripts to be approved through `allowBuilds`; without this, the freshly scaffolded
// demo breaks on first local DB / dev commands because
// `better-sqlite3`/`esbuild`/`workerd` never run their postinstall
// build steps. Honor both pnpm signals via `isPnpmProject` — a committed
Expand All @@ -2866,14 +2932,7 @@ export function ensureVoidDependency(root: string): boolean {
// `detectProjectState` and `runInitCommand` already treat as pnpm; the
// demo would then run `pnpm install` against the project but skip the
// allowlist, leaving the local DB / dev setup broken on first run.
if (isPnpmProject(root, pkg)) {
const before = JSON.stringify(asRecord(pkg.pnpm)?.onlyBuiltDependencies ?? null);
ensurePnpmBuiltDependencies(pkg);
const after = JSON.stringify(asRecord(pkg.pnpm)?.onlyBuiltDependencies ?? null);
if (before !== after) {
changed = true;
}
}
const pnpmConfigChanged = isPnpmProject(root, pkg) ? ensurePnpmAllowBuilds(root, pkg) : false;

if (changed) {
writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n');
Expand All @@ -2884,7 +2943,7 @@ export function ensureVoidDependency(root: string): boolean {
);
}

return changed;
return changed || pnpmConfigChanged;
}

interface AgentSetupSelection {
Expand Down
2 changes: 1 addition & 1 deletion packages/void/src/migrations/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function toBetterSqlite3Error(error: unknown): Error {

return new Error(
'db: better-sqlite3 is installed but its native binding was not built. ' +
"Add it to your 'onlyBuiltDependencies' and run `pnpm rebuild better-sqlite3`. See https://pnpm.io/settings#onlybuiltdependencies",
'Set `allowBuilds.better-sqlite3` to `true` in pnpm-workspace.yaml and run `pnpm rebuild better-sqlite3`. See https://pnpm.io/settings#allowbuilds',
);
}

Expand Down
39 changes: 22 additions & 17 deletions packages/void/test/unit/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ const mockIsStagingMode = vi.fn();
const mockListProjects = vi.fn();
const mockCreateProject = vi.fn();

function expectPnpmAllowBuilds(root: string): void {
const yaml = readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf-8');
expect(yaml).toContain('allowBuilds:\n');
expect(yaml).toContain(' better-sqlite3: true');
expect(yaml).toContain(' esbuild: true');
expect(yaml).toContain(' workerd: true');
expect(yaml).not.toContain('onlyBuiltDependencies:');
}

vi.mock('@clack/prompts', () => ({
intro: vi.fn(),
outro: (...args: Array<unknown>) => mockOutro(...args),
Expand Down Expand Up @@ -1815,9 +1824,7 @@ export default config;
expect(pkg.devDependencies['@rolldown/plugin-babel']).toBe('^0.2.3');
expect(pkg.devDependencies['@vitejs/plugin-react']).toBe('^6.0.1');
expect(pkg.devDependencies['babel-plugin-react-compiler']).toBe('^1.0.0');
expect(pkg.pnpm.onlyBuiltDependencies).toEqual(
expect.arrayContaining(['better-sqlite3', 'esbuild', 'workerd']),
);
expectPnpmAllowBuilds(root);

const reactViteConfig = readFileSync(join(root, 'vite.config.ts'), 'utf-8');
expect(reactViteConfig).toContain('from "@rolldown/plugin-babel"');
Expand Down Expand Up @@ -3858,14 +3865,14 @@ export default config;
});

// Round 44 regression: `scaffoldPagesApp` previously only wrote
// `pnpm.onlyBuiltDependencies` when `packageManager === 'pnpm'`. But
// pnpm build approvals when `packageManager === 'pnpm'`. But
// for a workspace subpackage with no local lockfile / Corepack pin,
// `detectProjectState` returns `'npm'` — the pnpm branch never fires
// and the subpackage's built-deps allowlist is silently dropped,
// which suppresses better-sqlite3 / esbuild / workerd postinstalls on
// the next workspace-root `pnpm install`. Gate the allowlist on the
// ancestor-aware `isPnpmProject` check instead.
it('writes pnpm.onlyBuiltDependencies for a pnpm workspace subpackage (Round 44)', async () => {
it('writes pnpm allowBuilds for a pnpm workspace subpackage (Round 44)', async () => {
const wsroot = mkdtempSync(join(tmpdir(), 'void-init-'));
try {
// Workspace root: pnpm-workspace.yaml with `packages/*`. The root
Expand All @@ -3874,7 +3881,7 @@ export default config;
writeFileSync(
join(wsroot, 'package.json'),
JSON.stringify(
{ name: 'workspace-root', private: true, packageManager: 'pnpm@10.33.0' },
{ name: 'workspace-root', private: true, packageManager: 'pnpm@11.0.8' },
null,
2,
) + '\n',
Expand All @@ -3895,11 +3902,11 @@ export default config;

const pkg = JSON.parse(readFileSync(join(subpkgRoot, 'package.json'), 'utf-8'));

// The built-deps allowlist MUST be present — this is the
// regression target. Mirrors the standalone-pnpm assertion above.
expect(pkg.pnpm?.onlyBuiltDependencies).toEqual(
expect.arrayContaining(['better-sqlite3', 'esbuild', 'workerd']),
);
// The built-deps allowlist MUST be present in the workspace
// config — this is the regression target. Mirrors the
// standalone-pnpm assertion above.
expectPnpmAllowBuilds(wsroot);
expect(pkg.pnpm).toBeUndefined();
} finally {
rmSync(wsroot, { recursive: true, force: true });
}
Expand All @@ -3916,7 +3923,7 @@ export default config;
writeFileSync(
join(wsroot, 'package.json'),
JSON.stringify(
{ name: 'workspace-root', private: true, packageManager: 'pnpm@10.33.0' },
{ name: 'workspace-root', private: true, packageManager: 'pnpm@11.0.8' },
null,
2,
) + '\n',
Expand All @@ -3940,7 +3947,7 @@ export default config;
}
});

it('writes BOTH pnpm.onlyBuiltDependencies AND packageManager pin in a standalone pnpm project (Round 44)', async () => {
it('writes BOTH pnpm allowBuilds AND packageManager pin in a standalone pnpm project (Round 44)', async () => {
// Symmetric proof that the standalone-pnpm path still writes both
// the allowlist AND the Corepack pin, after the Round 44 split.
const root = mkdtempSync(join(tmpdir(), 'void-init-'));
Expand All @@ -3957,10 +3964,8 @@ export default config;
await runInitCommand(root);

const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
expect(pkg.pnpm?.onlyBuiltDependencies).toEqual(
expect.arrayContaining(['better-sqlite3', 'esbuild', 'workerd']),
);
expect(pkg.packageManager).toBe('pnpm@10.33.0');
expectPnpmAllowBuilds(root);
expect(pkg.packageManager).toBe('pnpm@11.0.8');
} finally {
rmSync(root, { recursive: true, force: true });
}
Expand Down
2 changes: 1 addition & 1 deletion playground/ssr/blog/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ This guide walks you through setting up a Void project with SSR and blog support
## Prerequisites

- Node.js 22+
- pnpm 10+
- pnpm 11+

## Steps

Expand Down
17 changes: 12 additions & 5 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ packages:
- playground/*
- playground-local/*

allowBuilds:
'@parcel/watcher': false
'@prisma/engines': false
better-sqlite3: true
esbuild: true
lmdb: false
msgpackr-extract: false
prisma: false
sharp: false
vue-demi: false
workerd: true

catalog:
'@clack/prompts': ^1.3.0
'@cloudflare/workers-types': ^4.20260508.1
Expand All @@ -15,11 +27,6 @@ catalog:
vitest: npm:@voidzero-dev/vite-plus-test@0.1.20
wrangler: ^4.90.0

onlyBuiltDependencies:
- better-sqlite3
- esbuild
- workerd

overrides:
esbuild: ^0.27.3
vite: 'catalog:'
Expand Down