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
16 changes: 8 additions & 8 deletions docs/guide/app-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,14 @@ At the edge, the resolution order is:

When running `void deploy` and no `inference.appType` is set in `void.json`, the detection logic runs in this order:

| Priority | Condition | Type |
| -------- | ----------------------------------------------------------------------------------------- | ----------------------------- |
| 1 | `--dir` flag | Static (or SPA with `--spa`) |
| 2 | Known SSG in dependencies (`vitepress`, `@docusaurus/core`) | Static, builds with SSG CLI |
| 3 | `@tanstack/react-start`, `@react-router/dev`, `@sveltejs/kit`, `nuxt`, or `astro` in deps | Framework |
| 4 | Backend files exist (`routes/`, `pages/`, `middleware/`, `crons/`, `queues/`, SSR entry) | Void app |
| 5 | `vite` in dependencies, no backend files | SPA, builds with `vite build` |
| 6 | `dist/index.html` or `./index.html` exists | Static (no build step) |
| Priority | Condition | Type |
| -------- | ----------------------------------------------------------------------------------------- | ------------------------------------------- |
| 1 | `--dir` flag | Static (or SPA with `--spa`) |
| 2 | Known SSG in dependencies (`vitepress`, `@docusaurus/core`) | Static, builds with SSG CLI |
| 3 | `@tanstack/react-start`, `@react-router/dev`, `@sveltejs/kit`, `nuxt`, or `astro` in deps | Framework |
| 4 | Backend files exist (`routes/`, `pages/`, `middleware/`, `crons/`, `queues/`, SSR entry) | Void app |
| 5 | `vite` or `vite-plus` in dependencies, no backend files | SPA, builds with `vite build` or `vp build` |
| 6 | `dist/index.html` or `./index.html` exists | Static (no build step) |

## Explicit Configuration

Expand Down
51 changes: 34 additions & 17 deletions packages/void/src/cli/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,14 @@ import { scanWebSocketRoutesSync } from "../router/websocket";
import { validateSsrEntry } from "../ssr/entry";
import { scanPages } from "../pages/scan";
import { collectPrerenderPaths, collectPrerenderPathsNode } from "../prerender";
import { detectPreset, FRAMEWORK_PRESETS, type StaticPreset, type FrameworkPreset } from "./preset";
import {
detectPreset,
FRAMEWORK_PRESETS,
formatProjectCommand,
resolveProjectCommand,
type StaticPreset,
type FrameworkPreset,
} from "./preset";
import {
readConfig,
resolveBindingNames,
Expand Down Expand Up @@ -341,10 +348,13 @@ async function runFrameworkDeploy(
const dialect = getDatabaseDialect(voidConfig);

// 1. Build
const buildCmd = voidConfig.inference?.build ?? formatProjectCommand(root, fwPreset.buildCommand);
if (!skipBuild) {
const buildCmd = voidConfig.inference?.build ?? fwPreset.buildCommand;
log.step(`Building (${buildCmd})...`);
execSync(buildCmd, { cwd: root, stdio: "inherit" });
execSync(voidConfig.inference?.build ?? resolveProjectCommand(root, fwPreset.buildCommand), {
cwd: root,
stdio: "inherit",
});
}

// 2. Verify output paths
Expand All @@ -362,9 +372,7 @@ async function runFrameworkDeploy(
const redirectRules: RedirectRule[] = [...(fwRedirectRules ?? []), ...(cfgRedirectRules ?? [])];

if (!existsSync(workerDir)) {
log.error(
`Worker output not found at "${fwPreset.workerDir}". Run "${fwPreset.buildCommand}" first.`,
);
log.error(`Worker output not found at "${fwPreset.workerDir}". Run "${buildCmd}" first.`);
process.exit(1);
}

Expand Down Expand Up @@ -573,20 +581,21 @@ async function runFullDeploy(
): Promise<void> {
// 3. Build (child process to avoid Vite API side effects on bundle output)
if (!skipBuild) {
log.step("Building...");
const viteBin = fileURLToPath(
new URL("bin/vite.js", import.meta.resolve("vite/package.json")).href,
);
const buildCmd = formatProjectCommand(root, "vite build");
log.step(`Building (${buildCmd})...`);
try {
execFileSync(process.execPath, [viteBin, "build"], { cwd: root, stdio: "inherit" });
execSync(resolveProjectCommand(root, "vite build"), { cwd: root, stdio: "inherit" });
} catch (err: any) {
process.exit(err.status ?? 1);
}
}

// 4. Resolve build output paths from build artifacts (avoids re-resolving
// Vite config in the main process — plugins only run once inside the build)
const distDir = resolveDistDir(root, fwPreset);
const frameworkBuildCmd = fwPreset
? formatProjectCommand(root, fwPreset.buildCommand)
: undefined;
const distDir = resolveDistDir(root, fwPreset, frameworkBuildCmd);

// Detect non-default assetsDir from Vite client manifest for _headers generation
let assetsPrefix = "assets";
Expand Down Expand Up @@ -679,7 +688,7 @@ async function runFullDeploy(
process.exit(1);
}
}
const workerDirName = resolveWorkerDirName(distDir, fwPreset);
const workerDirName = resolveWorkerDirName(distDir, fwPreset, frameworkBuildCmd);

const schedules = scanJobsSync(root).flatMap((job) =>
job.crons.map((cron) => ({
Expand Down Expand Up @@ -1137,15 +1146,19 @@ function loadDeployEnv(root: string): Record<string, string> {
*
* For non-framework (Void app) deploys, defaults to "dist".
*/
export function resolveDistDir(root: string, fwPreset?: FrameworkPreset): string {
export function resolveDistDir(
root: string,
fwPreset?: FrameworkPreset,
buildCommand?: string,
): string {
if (fwPreset) {
// Derive the top-level build dir from the preset's workerDir
// e.g. "build/server" → "build", "dist/ssr" → "dist"
const topDir = fwPreset.workerDir.split("/")[0];
const distDir = join(root, topDir);
if (!existsSync(distDir)) {
throw new Error(
`Build output directory "${topDir}" not found. Run "${fwPreset.buildCommand}" first.`,
`Build output directory "${topDir}" not found. Run "${buildCommand ?? fwPreset.buildCommand}" first.`,
);
}
return distDir;
Expand All @@ -1161,15 +1174,19 @@ export function resolveDistDir(root: string, fwPreset?: FrameworkPreset): string
*
* For non-framework (Void app) deploys, probes for known subdirectories.
*/
export function resolveWorkerDirName(distDir: string, fwPreset?: FrameworkPreset): string {
export function resolveWorkerDirName(
distDir: string,
fwPreset?: FrameworkPreset,
buildCommand?: string,
): string {
if (fwPreset) {
// Derive the subdirectory from the preset's workerDir
// e.g. "build/server" → "server", "dist/ssr" → "ssr"
const parts = fwPreset.workerDir.split("/");
const subDir = parts.slice(1).join("/");
if (!existsSync(join(distDir, subDir))) {
throw new Error(
`Worker output not found at "${fwPreset.workerDir}". Run "${fwPreset.buildCommand}" first.`,
`Worker output not found at "${fwPreset.workerDir}". Run "${buildCommand ?? fwPreset.buildCommand}" first.`,
);
}
return subDir;
Expand Down
16 changes: 10 additions & 6 deletions packages/void/src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
SCAFFOLD_GITIGNORE_LINES,
type ScaffoldFramework,
} from "./template";
import { formatProjectCommand } from "./preset";
import {
detectAgents,
getAgentById,
Expand Down Expand Up @@ -482,15 +483,15 @@ async function promptScaffoldFramework(): Promise<ScaffoldFramework> {
}

/** Get the dev command for a detected framework. */
function getDevCommand(framework?: DetectedFramework): string {
if (!framework) return "vite dev";
function getDevCommand(root: string, framework?: DetectedFramework): string {
if (!framework) return formatProjectCommand(root, "vite dev");
switch (framework.name) {
case "nuxt":
return "nuxt dev";
case "astro":
return "astro dev";
default:
return "vite dev";
return formatProjectCommand(root, "vite dev");
}
}

Expand Down Expand Up @@ -732,8 +733,9 @@ function scaffoldPagesApp(
applyDatabaseDialect(root, dialect);

if (dialect === "postgresql") {
const devCommand = getDevCommand(root);
note(
"Add DATABASE_URL to .env.local before running `vite dev`.\n" +
`Add DATABASE_URL to .env.local before running \`${devCommand}\`.\n` +
"Void uses that direct connection for local PostgreSQL development.",
"PostgreSQL local dev",
);
Expand All @@ -745,6 +747,8 @@ function scaffoldPagesApp(
}

function scaffoldDemo(root: string): void {
const devCommand = getDevCommand(root);

// Migration
mkdirSync(join(root, "db", "migrations"), { recursive: true });
writeFileSync(
Expand Down Expand Up @@ -812,7 +816,7 @@ console.log(posts);

note(
"SQL migration → DB types → Route handler → Route types → Typed fetch\n\n" +
"Run `vite dev` and open src/demo.ts to see the types in action.\n" +
`Run \`${devCommand}\` and open src/demo.ts to see the types in action.\n` +
"Try changing the migration or handler — types update automatically.",
"Type Safety Demo",
);
Expand Down Expand Up @@ -1118,5 +1122,5 @@ export async function runInitCommand(root: string, initArgs?: InitArgs): Promise
}
}

outro(`Run \`${getDevCommand(framework)}\` to start developing`);
outro(`Run \`${getDevCommand(root, framework)}\` to start developing`);
}
58 changes: 48 additions & 10 deletions packages/void/src/cli/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ export interface FrameworkPreset {
* Known output conventions for meta-frameworks.
* Keyed by the framework name from `detectFramework()`.
*
* Class A frameworks (tanstack-start, react-router, vinext) use `vite build`
* and have their output structure determined by the framework's Vite plugin.
* Class A frameworks (tanstack-start, react-router, vinext) use the project's
* Vite-compatible build command (`vite build` or `vp build`) and have their
* output structure determined by the framework's Vite plugin.
*
* Class B/C frameworks (sveltekit, nuxt, astro) use their own build CLI
* and have framework-specific output conventions.
Expand Down Expand Up @@ -126,6 +127,26 @@ export interface StaticPreset {
appType: "spa" | "static";
}

/**
* Resolve a project-local command, rewriting `vite` to `vp` when the project
* depends on Vite+ and preferring node_modules/.bin when available.
*/
export function resolveProjectCommand(root: string, command: string): string {
const [bin, ...args] = command.trim().split(/\s+/);
const resolvedBin = localBin(root, resolveCommandBin(root, bin));
return args.length > 0 ? `${resolvedBin} ${args.join(" ")}` : resolvedBin;
}

/**
* Format a user-facing command string, rewriting `vite` to `vp` when the
* project depends on Vite+ without expanding to an absolute local bin path.
*/
export function formatProjectCommand(root: string, command: string): string {
const [bin, ...args] = command.trim().split(/\s+/);
const formattedBin = resolveCommandBin(root, bin);
return args.length > 0 ? `${formattedBin} ${args.join(" ")}` : formattedBin;
}

/**
* Detect a static deploy preset based on the project root.
* Returns null when the project requires a full worker deploy.
Expand All @@ -152,10 +173,8 @@ export function detectPreset(
const overrides = preset.resolve?.(root);
const cmd = overrides?.buildCommand ?? preset.buildCommand;
const out = overrides?.outputDir ?? preset.outputDir;
const bin = cmd.split(" ")[0];
const args = cmd.slice(bin.length);
return {
buildCommand: `${localBin(root, bin)}${args}`,
buildCommand: resolveProjectCommand(root, cmd),
outputDir: resolve(root, out),
appType: preset.appType,
};
Expand All @@ -168,10 +187,10 @@ export function detectPreset(
return null;
}

// 4. Vite in deps → static SPA
if (pkg && hasVite(pkg)) {
// 4. Vite/Vite+ in deps → static SPA
if (pkg && hasViteLike(pkg)) {
return {
buildCommand: `${localBin(root, "vite")} build`,
buildCommand: resolveProjectCommand(root, "vite build"),
outputDir: resolve(root, "dist"),
appType: "spa",
};
Expand Down Expand Up @@ -206,6 +225,13 @@ function localBin(root: string, name: string): string {
return existsSync(bin) ? bin : name;
}

function resolveCommandBin(root: string, name: string): string {
if (name === "vite") {
return detectViteBinary(root);
}
return name;
}

function readPackageJson(root: string): Record<string, unknown> | null {
try {
return JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
Expand All @@ -221,8 +247,20 @@ function getAllDeps(pkg: Record<string, unknown>): Record<string, string> {
};
}

function hasVite(pkg: Record<string, unknown>): boolean {
return "vite" in getAllDeps(pkg);
function hasViteLike(pkg: Record<string, unknown>): boolean {
const deps = getAllDeps(pkg);
return "vite" in deps || "vite-plus" in deps;
}

function detectViteBinary(root: string): "vite" | "vp" {
const pkg = readPackageJson(root);
if (pkg) {
const deps = getAllDeps(pkg);
if ("vite-plus" in deps) {
return "vp";
}
}
return "vite";
}

/**
Expand Down
20 changes: 20 additions & 0 deletions packages/void/test/unit/deploy-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ describe("resolveDistDir", () => {
'Build output directory "build" not found',
);
});

it("uses the resolved build command in framework error messages", () => {
const root = mkdtempSync(join(tmpdir(), "void-test-"));
tempDirs.push(root);

expect(() => resolveDistDir(root, FRAMEWORK_PRESETS.sveltekit, "vp build")).toThrow(
'Run "vp build" first.',
);
});
});

describe("resolveWorkerDirName", () => {
Expand Down Expand Up @@ -181,6 +190,17 @@ describe("resolveWorkerDirName", () => {
);
});

it("uses the resolved build command when worker output is missing", () => {
const root = mkdtempSync(join(tmpdir(), "void-test-"));
tempDirs.push(root);
const dist = join(root, "dist");
mkdirSync(dist, { recursive: true });

expect(() => resolveWorkerDirName(dist, FRAMEWORK_PRESETS.sveltekit, "vp build")).toThrow(
'Run "vp build" first.',
);
});

it("throws when no worker dir exists without preset", () => {
const root = mkdtempSync(join(tmpdir(), "void-test-"));
tempDirs.push(root);
Expand Down
Loading
Loading