Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/guide/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ yarn void init
```

```sh [bun]
# bunx runs Void with the `node` on your PATH: use Node.js 24.22+ or 26.12+
bun add -D void
bunx void init
```
Expand Down
2 changes: 1 addition & 1 deletion docs/integrations/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ Set production server values with `void secret put <NAME>`. Void preserves exist

### Readiness and rollback

Void normally verifies an uploaded version before sending it traffic. If readiness or trigger updates fail, it restores the previous deployment. Finish or cancel any gradual rollout in Cloudflare before deploying through Void.
Void normally verifies an uploaded version before sending it traffic. If readiness or trigger updates fail, it restores the previous deployment. If trigger updates fail and Void cannot restore the previous version's triggers, for example because another tool deployed it or it is an empty draft, the new version stays live and the error says which version is serving traffic. Finish or cancel any gradual rollout in Cloudflare before deploying through Void.

For an existing Durable Object Worker that Cloudflare cannot stage, review the changes and use:

Expand Down
2 changes: 2 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ Use this page as a command reference. If you are setting up a project for the fi

The docs use `void` for brevity. Outside package scripts, run it with your package manager: `npx void`, `pnpm void`, `yarn void`, `bunx void`, or `vpx void`.

The CLI needs Node.js 24.22+ or 26.12+. `npx`, `bunx` and package scripts use the `node` on your `PATH`; `vpx` uses the version in `.node-version`.

Alternatively, you can add `./node_modules/.bin` to your `PATH` so that you can invoke `void` directly when you are in the root directory of your app.

:::warning ⚠️ Prefer local install
Expand Down
97 changes: 14 additions & 83 deletions packages/create-void/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,86 +1,17 @@
#!/usr/bin/env node

import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import * as p from '@voidzero-dev/vite-plus-prompts';
import { resolve } from 'pathslash';
import pc from 'picocolors';
import { parseCreateArgs, validateProjectName } from './args.ts';
import { detectPM, pmExec, pmInstall, run, writePnpmBuildPolicy } from './pm.ts';

const pm = detectPM();

async function main() {
console.log();

const createArgs = parseCreateArgs(process.argv.slice(2));
let appName = createArgs.appName;

if (!appName && !process.stdin.isTTY) {
p.log.error('create-void: Pass a project name when running without an interactive terminal.');
process.exit(1);
}

if (!appName) {
const name = await p.text({
message: 'Project name:',
defaultValue: 'my-app',
placeholder: 'my-app',
validate: validateProjectName,
});
if (p.isCancel(name) || typeof name !== 'string') {
p.cancel('Cancelled.');
process.exit(0);
}
appName = name;
}

const targetDir = resolve(appName);

if (existsSync(targetDir)) {
const entries = readdirSync(targetDir);
if (entries.length > 0) {
p.log.error(`create-void: Directory '${pc.bold(appName)}' is not empty.`);
process.exit(1);
}
}

// The release publishes the scaffolder and CLI at the same version. Preserve
// that exact version, including prereleases, instead of switching to latest.
const { version: voidVersion } = JSON.parse(
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
) as { version: string };

mkdirSync(targetDir, { recursive: true });

const pkg = {
name: appName,
private: true,
type: 'module',
devDependencies: {
void: voidVersion,
},
};

writeFileSync(resolve(targetDir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n');
// pnpm 12 fails the first install when a dependency has an unapproved build
// script. Write Void's narrow policy before installing the CLI so a fresh
// project can bootstrap without disabling pnpm's build-script protection.
writePnpmBuildPolicy(targetDir, pm);

p.log.step('Installing Void...');
await run(pmInstall(pm), targetDir);

p.log.step('Scaffolding starter...');
await run([...pmExec(pm), 'void', 'init', ...createArgs.initArgs], targetDir);

p.log.step('Installing starter dependencies...');
await run(pmInstall(pm), targetDir);

p.outro(`\n${pc.green(`✓ Created ${appName}`)}`, { withGuide: false });
p.outro(`cd ${appName} && ${pm === 'npm' ? 'npm run' : pm} dev`);
// The bin entry. It imports only the dependency-free runtime check, so an
// unsupported Node or Bun gets a clear message instead of a link-time
// SyntaxError from a newer API in the bundled prompts. `void init` stops on an
// unsupported runtime too, so checking first keeps a failed run from leaving a
// half-created folder that blocks the next attempt.
import { unsupportedRuntimeMessage } from './runtime-support.ts';

const message = unsupportedRuntimeMessage(process.versions, process.execPath, 'create-void');
if (message) {
process.stderr.write(`${message}\n`);
// exitCode instead of exit() so the message is flushed when stderr is a pipe.
process.exitCode = 1;
} else {
await import('./main.ts');
}

main().catch((err) => {
p.log.error(err.message);
process.exit(1);
});
84 changes: 84 additions & 0 deletions packages/create-void/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import * as p from '@voidzero-dev/vite-plus-prompts';
import { resolve } from 'pathslash';
import pc from 'picocolors';
import { parseCreateArgs, validateProjectName } from './args.ts';
import { detectPM, pmExec, pmInstall, run, writePnpmBuildPolicy } from './pm.ts';

const pm = detectPM();

async function main() {
console.log();

const createArgs = parseCreateArgs(process.argv.slice(2));
let appName = createArgs.appName;

if (!appName && !process.stdin.isTTY) {
p.log.error('create-void: Pass a project name when running without an interactive terminal.');
process.exit(1);
}

if (!appName) {
const name = await p.text({
message: 'Project name:',
defaultValue: 'my-app',
placeholder: 'my-app',
validate: validateProjectName,
});
if (p.isCancel(name) || typeof name !== 'string') {
p.cancel('Cancelled.');
process.exit(0);
}
appName = name;
}

const targetDir = resolve(appName);

if (existsSync(targetDir)) {
const entries = readdirSync(targetDir);
if (entries.length > 0) {
p.log.error(`create-void: Directory '${pc.bold(appName)}' is not empty.`);
process.exit(1);
}
}

// The release publishes the scaffolder and CLI at the same version. Preserve
// that exact version, including prereleases, instead of switching to latest.
const { version: voidVersion } = JSON.parse(
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
) as { version: string };

mkdirSync(targetDir, { recursive: true });

const pkg = {
name: appName,
private: true,
type: 'module',
devDependencies: {
void: voidVersion,
},
};

writeFileSync(resolve(targetDir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n');
// pnpm 12 fails the first install when a dependency has an unapproved build
// script. Write Void's narrow policy before installing the CLI so a fresh
// project can bootstrap without disabling pnpm's build-script protection.
writePnpmBuildPolicy(targetDir, pm);

p.log.step('Installing Void...');
await run(pmInstall(pm), targetDir);

p.log.step('Scaffolding starter...');
await run([...pmExec(pm), 'void', 'init', ...createArgs.initArgs], targetDir);

p.log.step('Installing starter dependencies...');
await run(pmInstall(pm), targetDir);

p.outro(`\n${pc.green(`✓ Created ${appName}`)}`, { withGuide: false });
p.outro(`cd ${appName} && ${pm === 'npm' ? 'npm run' : pm} dev`);
}

main().catch((err) => {
p.log.error(err.message);
process.exit(1);
});
130 changes: 130 additions & 0 deletions packages/create-void/src/runtime-support.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Checks the runtime before the CLI loads. On an older Node, a static import of a
// newer API (such as `setGlobalProxyFromEnv` from `node:http`) fails at link time
// with a SyntaxError before any code can run, and child processes reuse
// `process.execPath`, so a deploy would stop halfway. This file must therefore
// load on any Node or Bun release: it has no imports and uses no new APIs.
//
// packages/void/src/cli/runtime-support.ts and packages/create-void/src/runtime-support.ts
// are byte-identical copies, because create-void runs before `void` is installed.
// A unit test keeps the two files and both `engines.node` fields equal.

/** Must equal `engines.node` in packages/void and packages/create-void. */
export const NODE_ENGINES = '^24.22.0 || >=26.12.0';

/**
* Bun is not a documented runtime, but `bunx --bun` and Bun-only images (where
* `node` is Bun) run the CLI on it. Bun reports a Node version below 26.12, so
* it gets its own floor: 1.4.0 is the first release where Void's Cloudflare
* helper process loads. This lets working setups keep working.
*/
export const BUN_MINIMUM = '1.4.0';

type Version = [major: number, minor: number, patch: number];

export type RuntimeVersions = { readonly node?: string; readonly bun?: string };

function isDigits(text: string): boolean {
if (text.length === 0) {
return false;
}
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
if (code < 48 || code > 57) {
return false;
}
}
return true;
}

/**
* Parses `1.2.3`, with an optional leading `v`. A pre-release or build tag
* (`27.0.0-nightly2026...`, `1.4.0+abc`) is ignored, so a nightly counts as its
* release line.
*/
export function parseVersion(text: string): Version | null {
let core = text.trim();
if (core.startsWith('v')) {
core = core.slice(1);
}
for (const separator of ['-', '+']) {
const index = core.indexOf(separator);
if (index !== -1) {
core = core.slice(0, index);
}
}
const parts = core.split('.');
if (parts.length !== 3 || !parts.every(isDigits)) {
return null;
}
return [Number(parts[0]), Number(parts[1]), Number(parts[2])];
}

function compareVersions(a: Version, b: Version): number {
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
}

type Clause = { caret: boolean; version: Version };

/** Supports only the forms used in `engines.node`: `^x.y.z` and `>=x.y.z`, joined by `||`. */
function parseRange(range: string): Array<Clause> {
return range.split('||').map((raw) => {
const clause = raw.trim();
const caret = clause.startsWith('^');
const prefix = caret ? '^' : clause.startsWith('>=') ? '>=' : null;
const version = prefix === null ? null : parseVersion(clause.slice(prefix.length));
if (!version) {
throw new Error(`runtime-support: unsupported range clause '${clause}'`);
}
return { caret, version };
});
}

export function satisfiesRange(version: Version, range: string): boolean {
return parseRange(range).some(
({ caret, version: floor }) =>
compareVersions(version, floor) >= 0 && (!caret || version[0] === floor[0]),
);
}

/** `^24.22.0 || >=26.12.0` -> `24.22+ or 26.12+`. */
function describeRange(range: string): string {
return parseRange(range)
.map(({ version: [major, minor, patch] }) =>
patch === 0 ? `${major}.${minor}+` : `${major}.${minor}.${patch}+`,
)
.join(' or ');
}

/**
* Returns the error to print when this runtime cannot run the CLI, or `null`.
* The text names only the runtime version and executable path, never env or argv.
*/
export function unsupportedRuntimeMessage(
versions: RuntimeVersions,
execPath: string,
command = 'void',
): string | null {
const nodeRange = describeRange(NODE_ENGINES);
if (versions.bun !== undefined) {
const bun = parseVersion(versions.bun);
const minimum = parseVersion(BUN_MINIMUM);
if (bun && minimum && compareVersions(bun, minimum) >= 0) {
return null;
}
return (
`${command}: Bun ${BUN_MINIMUM} or later is required. Found Bun ${versions.bun} (${execPath}).\n` +
`Update Bun, or run ${command} on Node.js ${nodeRange}.`
);
}
const node = versions.node === undefined ? null : parseVersion(versions.node);
if (node && satisfiesRange(node, NODE_ENGINES)) {
return null;
}
const found = versions.node === undefined ? 'an unknown version' : `v${versions.node}`;
return (
`${command}: Node.js ${nodeRange} is required. Found Node.js ${found} (${execPath}).\n` +
'bunx, npx and package scripts use the `node` on PATH; vp/vpx use .node-version.\n' +
'Update .node-version or the node on PATH. In GitHub Actions add actions/setup-node\n' +
'with `node-version: lts/*` and `check-latest: true` before this step.'
);
}
56 changes: 56 additions & 0 deletions packages/create-void/test/unit/entry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test';

const ENTRY = join(import.meta.dirname, '..', '..', 'src', 'index.ts');

let root: string;

beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'create-void-entry-'));
});

afterAll(() => {
rmSync(root, { recursive: true, force: true });
});

describe('create-void entry', () => {
it('prints the runtime message before any other module links', () => {
// An old Node fails to link a module that imports a newer API (for example `styleText`
// from node:util in the bundled prompts). The hook makes every module other than the
// entry and the runtime check fail the same way, so the message must come first.
const preload = join(root, 'old-node.mjs');
writeFileSync(
preload,
`import { registerHooks } from 'node:module';
Object.defineProperty(process, 'versions', { value: { ...process.versions, node: '18.20.8' } });
const allowed = [${JSON.stringify(pathToFileURL(ENTRY).href)}, ${JSON.stringify(
pathToFileURL(join(ENTRY, '..', 'runtime-support.ts')).href,
)}];
registerHooks({
resolve(specifier, context, next) {
const result = next(specifier, context);
if (!allowed.includes(result.url)) {
throw new SyntaxError('cannot link ' + result.url);
}
return result;
},
});
`,
);
const result = spawnSync(
process.execPath,
['--import', pathToFileURL(preload).href, ENTRY, 'my-app'],
{ cwd: root, encoding: 'utf8', env: { PATH: process.env.PATH } },
);

expect(result.status).toBe(1);
expect(result.stderr.split('\n')[0]).toBe(
`create-void: Node.js 24.22+ or 26.12+ is required. Found Node.js v18.20.8 (${process.execPath}).`,
);
expect(readdirSync(root)).not.toContain('my-app');
});
});
Loading
Loading