add cloudflare workers support for all frontends (#366)

This commit is contained in:
Aman Varshney
2025-07-05 15:51:26 +05:30
committed by GitHub
parent 6499f8cf04
commit d2674270a4
53 changed files with 1213 additions and 159 deletions

View File

@@ -64,6 +64,7 @@
"picocolors": "^1.1.1",
"posthog-node": "^5.1.1",
"trpc-cli": "^0.9.2",
"ts-morph": "^26.0.0",
"zod": "^3.25.67"
},
"devDependencies": {

View File

@@ -24,6 +24,7 @@ export const DEFAULT_CONFIG: ProjectConfig = {
backend: "hono",
runtime: "bun",
api: "trpc",
webDeploy: "none",
};
export const dependencyVersionMap = {
@@ -45,8 +46,8 @@ export const dependencyVersionMap = {
mongoose: "^8.14.0",
"vite-plugin-pwa": "^0.21.2",
"@vite-pwa/assets-generator": "^0.2.6",
"vite-plugin-pwa": "^1.0.1",
"@vite-pwa/assets-generator": "^1.0.0",
"@tauri-apps/cli": "^2.4.0",
@@ -107,13 +108,17 @@ export const dependencyVersionMap = {
"@tanstack/solid-query": "^5.75.0",
"@tanstack/solid-query-devtools": "^5.75.0",
wrangler: "^4.20.0",
wrangler: "^4.23.0",
"@cloudflare/vite-plugin": "^1.9.0",
"@opennextjs/cloudflare": "^1.3.0",
"nitro-cloudflare-dev": "^0.2.2",
"@sveltejs/adapter-cloudflare": "^7.0.4",
} as const;
export type AvailableDependencies = keyof typeof dependencyVersionMap;
export const ADDON_COMPATIBILITY = {
pwa: ["tanstack-router", "react-router", "solid"],
pwa: ["tanstack-router", "react-router", "solid", "next"],
tauri: ["tanstack-router", "react-router", "nuxt", "svelte", "solid"],
biome: [],
husky: [],

View File

@@ -18,7 +18,7 @@ function exitWithError(message: string): never {
}
export async function addAddonsToProject(
input: AddInput & { addons: Addons[] },
input: AddInput & { addons: Addons[]; suppressInstallMessage?: boolean },
): Promise<void> {
try {
const projectDir = input.projectDir || process.cwd();
@@ -55,6 +55,7 @@ export async function addAddonsToProject(
install: input.install || false,
dbSetup: detectedConfig.dbSetup || "none",
api: detectedConfig.api || "none",
webDeploy: detectedConfig.webDeploy || "none",
};
for (const addon of input.addons) {
@@ -88,7 +89,7 @@ export async function addAddonsToProject(
projectDir,
packageManager: config.packageManager,
});
} else {
} else if (!input.suppressInstallMessage) {
log.info(
pc.yellow(
`Run ${pc.bold(

View File

@@ -0,0 +1,113 @@
import path from "node:path";
import { cancel, log } from "@clack/prompts";
import pc from "picocolors";
import type { AddInput, ProjectConfig, WebDeploy } from "../../types";
import { updateBtsConfig } from "../../utils/bts-config";
import { setupWebDeploy } from "../setup/web-deploy-setup";
import {
detectProjectConfig,
isBetterTStackProject,
} from "./detect-project-config";
import { installDependencies } from "./install-dependencies";
import { setupDeploymentTemplates } from "./template-manager";
function exitWithError(message: string): never {
cancel(pc.red(message));
process.exit(1);
}
export async function addDeploymentToProject(
input: AddInput & { webDeploy: WebDeploy; suppressInstallMessage?: boolean },
): Promise<void> {
try {
const projectDir = input.projectDir || process.cwd();
const isBetterTStack = await isBetterTStackProject(projectDir);
if (!isBetterTStack) {
exitWithError(
"This doesn't appear to be a Better-T Stack project. Please run this command from the root of a Better-T Stack project.",
);
}
const detectedConfig = await detectProjectConfig(projectDir);
if (!detectedConfig) {
exitWithError(
"Could not detect the project configuration. Please ensure this is a valid Better-T Stack project.",
);
}
if (detectedConfig.webDeploy === input.webDeploy) {
exitWithError(
`${input.webDeploy} deployment is already configured for this project.`,
);
}
if (input.webDeploy === "workers") {
const compatibleFrontends = [
"tanstack-router",
"react-router",
"solid",
"next",
"svelte",
];
const hasCompatible = detectedConfig.frontend?.some((f) =>
compatibleFrontends.includes(f),
);
if (!hasCompatible) {
exitWithError(
"Cloudflare Workers deployment requires a compatible web frontend (tanstack-router, react-router, solid, next, or svelte).",
);
}
}
const config: ProjectConfig = {
projectName: detectedConfig.projectName || path.basename(projectDir),
projectDir,
relativePath: ".",
database: detectedConfig.database || "none",
orm: detectedConfig.orm || "none",
backend: detectedConfig.backend || "none",
runtime: detectedConfig.runtime || "none",
frontend: detectedConfig.frontend || [],
addons: detectedConfig.addons || [],
examples: detectedConfig.examples || [],
auth: detectedConfig.auth || false,
git: false,
packageManager:
input.packageManager || detectedConfig.packageManager || "npm",
install: input.install || false,
dbSetup: detectedConfig.dbSetup || "none",
api: detectedConfig.api || "none",
webDeploy: input.webDeploy,
};
log.info(
pc.green(
`Adding ${input.webDeploy} deployment to ${config.frontend.join("/")}`,
),
);
await setupDeploymentTemplates(projectDir, config);
await setupWebDeploy(config);
await updateBtsConfig(projectDir, { webDeploy: input.webDeploy });
if (config.install) {
await installDependencies({
projectDir,
packageManager: config.packageManager,
});
} else if (!input.suppressInstallMessage) {
log.info(
pc.yellow(
`Run ${pc.bold(
`${config.packageManager} install`,
)} to install dependencies`,
),
);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
exitWithError(`Error adding deployment: ${message}`);
}
}

View File

@@ -6,6 +6,7 @@ import { DEFAULT_CONFIG } from "../../constants";
import { getAddonsToAdd } from "../../prompts/addons";
import { gatherConfig } from "../../prompts/config-prompts";
import { getProjectName } from "../../prompts/project-name";
import { getDeploymentToAdd } from "../../prompts/web-deploy";
import type { AddInput, CreateInput, ProjectConfig } from "../../types";
import { trackProjectCreation } from "../../utils/analytics";
import { displayConfig } from "../../utils/display-config";
@@ -17,8 +18,10 @@ import {
import { renderTitle } from "../../utils/render-title";
import { getProvidedFlags, processAndValidateFlags } from "../../validation";
import { addAddonsToProject } from "./add-addons";
import { addDeploymentToProject } from "./add-deployment";
import { createProject } from "./create-project";
import { detectProjectConfig } from "./detect-project-config";
import { installDependencies } from "./install-dependencies";
export async function createProjectHandler(
input: CreateInput & { projectName?: string },
@@ -135,45 +138,84 @@ export async function createProjectHandler(
export async function addAddonsHandler(input: AddInput): Promise<void> {
try {
const projectDir = input.projectDir || process.cwd();
const detectedConfig = await detectProjectConfig(projectDir);
if (!detectedConfig) {
cancel(
pc.red(
"Could not detect project configuration. Please ensure this is a valid Better-T Stack project.",
),
);
process.exit(1);
}
if (!input.addons || input.addons.length === 0) {
const projectDir = input.projectDir || process.cwd();
const detectedConfig = await detectProjectConfig(projectDir);
if (!detectedConfig) {
cancel(
pc.red(
"Could not detect project configuration. Please ensure this is a valid Better-T Stack project.",
),
);
process.exit(1);
}
const addonsPrompt = await getAddonsToAdd(
detectedConfig.frontend || [],
detectedConfig.addons || [],
);
if (addonsPrompt.length === 0) {
outro(
pc.yellow(
"No addons to add or all compatible addons are already present.",
),
);
return;
if (addonsPrompt.length > 0) {
input.addons = addonsPrompt;
}
input.addons = addonsPrompt;
}
if (!input.addons || input.addons.length === 0) {
outro(pc.yellow("No addons specified to add."));
if (!input.webDeploy) {
const deploymentPrompt = await getDeploymentToAdd(
detectedConfig.frontend || [],
detectedConfig.webDeploy,
);
if (deploymentPrompt !== "none") {
input.webDeploy = deploymentPrompt;
}
}
const packageManager =
input.packageManager || detectedConfig.packageManager || "npm";
let somethingAdded = false;
if (input.addons && input.addons.length > 0) {
await addAddonsToProject({
...input,
install: false,
suppressInstallMessage: true,
addons: input.addons,
});
somethingAdded = true;
}
if (input.webDeploy && input.webDeploy !== "none") {
await addDeploymentToProject({
...input,
install: false,
suppressInstallMessage: true,
webDeploy: input.webDeploy,
});
somethingAdded = true;
}
if (!somethingAdded) {
outro(pc.yellow("No addons or deployment configurations to add."));
return;
}
await addAddonsToProject({
...input,
addons: input.addons,
});
if (input.install) {
await installDependencies({
projectDir,
packageManager,
});
} else {
log.info(
pc.yellow(
`Run ${pc.bold(`${packageManager} install`)} to install dependencies`,
),
);
}
outro(pc.green("Add command completed successfully!"));
} catch (error) {
console.error(error);
process.exit(1);

View File

@@ -13,6 +13,7 @@ import {
generateCloudflareWorkerTypes,
setupRuntime,
} from "../setup/runtime-setup";
import { setupWebDeploy } from "../setup/web-deploy-setup";
import { createReadme } from "./create-readme";
import { setupEnvironmentVariables } from "./env-setup";
import { initializeGit } from "./git";
@@ -26,6 +27,7 @@ import {
setupAuthTemplate,
setupBackendFramework,
setupDbOrmTemplates,
setupDeploymentTemplates,
setupExamplesTemplate,
setupFrontendTemplates,
} from "./template-manager";
@@ -49,6 +51,8 @@ export async function createProject(options: ProjectConfig) {
}
await setupAddonsTemplate(projectDir, options);
await setupDeploymentTemplates(projectDir, options);
await setupApi(options);
if (!isConvex) {
@@ -70,6 +74,8 @@ export async function createProject(options: ProjectConfig) {
await handleExtras(projectDir, options);
await setupWebDeploy(options);
await setupEnvironmentVariables(options);
await updatePackageConfigurations(projectDir, options);
await createReadme(projectDir, options);

View File

@@ -23,6 +23,7 @@ export async function detectProjectConfig(
packageManager: btsConfig.packageManager,
dbSetup: btsConfig.dbSetup,
api: btsConfig.api,
webDeploy: btsConfig.webDeploy,
};
}

View File

@@ -30,5 +30,5 @@ export async function initializeGit(
}
await $({ cwd: projectDir })`git add -A`;
await $({ cwd: projectDir })`git commit -m ${"Initial commit"}`;
await $({ cwd: projectDir })`git commit -m ${"initial commit"}`;
}

View File

@@ -287,7 +287,7 @@ function getTauriInstructions(runCmd?: string): string {
function getPwaInstructions(): string {
return `\n${pc.bold("PWA with React Router v7:")}\n${pc.yellow(
"NOTE:",
)} There is a known compatibility issue between VitePWA and React Router v7.\nSee: https://github.com/vite-pwa/vite-plugin-pwa/issues/809`;
)} There is a known compatibility issue between VitePWA \nand React Router v7.See: https://github.com/vite-pwa/vite-plugin-pwa/issues/809`;
}
function getStarlightInstructions(runCmd?: string): string {

View File

@@ -832,3 +832,47 @@ export async function handleExtras(
}
}
}
export async function setupDeploymentTemplates(
projectDir: string,
context: ProjectConfig,
): Promise<void> {
if (context.webDeploy === "none") {
return;
}
if (context.webDeploy === "workers") {
const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) {
return;
}
const frontends = context.frontend;
const templateMap: Record<string, string> = {
"tanstack-router": "react/tanstack-router",
"react-router": "react/react-router",
solid: "solid",
next: "react/next",
nuxt: "nuxt",
svelte: "svelte",
};
for (const f of frontends) {
if (templateMap[f]) {
const deployTemplateSrc = path.join(
PKG_ROOT,
`templates/deploy/web/${templateMap[f]}`,
);
if (await fs.pathExists(deployTemplateSrc)) {
await processAndCopyFiles(
"**/*",
deployTemplateSrc,
webAppDir,
context,
);
}
}
}
}
}

View File

@@ -6,6 +6,7 @@ import type { Frontend, ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";
import { setupStarlight } from "./starlight-setup";
import { setupTauri } from "./tauri-setup";
import { addPwaToViteConfig } from "./vite-pwa-setup";
export async function setupAddons(config: ProjectConfig, isAddCommand = false) {
const { addons, frontend, projectDir } = config;
@@ -149,4 +150,10 @@ async function setupPwa(projectDir: string, frontends: Frontend[]) {
await fs.writeJson(clientPackageJsonPath, packageJson, { spaces: 2 });
}
const viteConfigTs = path.join(clientPackageDir, "vite.config.ts");
if (await fs.pathExists(viteConfigTs)) {
await addPwaToViteConfig(viteConfigTs, path.basename(projectDir));
}
}

View File

@@ -0,0 +1,73 @@
import {
type CallExpression,
Node,
type ObjectLiteralExpression,
SyntaxKind,
} from "ts-morph";
import { ensureArrayProperty, tsProject } from "../../utils/ts-morph";
export async function addPwaToViteConfig(
viteConfigPath: string,
projectName: string,
): Promise<void> {
const sourceFile = tsProject.addSourceFileAtPathIfExists(viteConfigPath);
if (!sourceFile) {
throw new Error("vite config not found");
}
const hasImport = sourceFile
.getImportDeclarations()
.some((imp) => imp.getModuleSpecifierValue() === "vite-plugin-pwa");
if (!hasImport) {
sourceFile.insertImportDeclaration(0, {
namedImports: ["VitePWA"],
moduleSpecifier: "vite-plugin-pwa",
});
}
const defineCall = sourceFile
.getDescendantsOfKind(SyntaxKind.CallExpression)
.find((expr) => {
const expression = expr.getExpression();
return (
Node.isIdentifier(expression) && expression.getText() === "defineConfig"
);
});
if (!defineCall) {
throw new Error("Could not find defineConfig call in vite config");
}
const callExpr = defineCall as CallExpression;
const configObject = callExpr.getArguments()[0] as
| ObjectLiteralExpression
| undefined;
if (!configObject) {
throw new Error("defineConfig argument is not an object literal");
}
const pluginsArray = ensureArrayProperty(configObject, "plugins");
const alreadyPresent = pluginsArray
.getElements()
.some((el) => el.getText().startsWith("VitePWA("));
if (!alreadyPresent) {
pluginsArray.addElement(
`VitePWA({
registerType: "autoUpdate",
manifest: {
name: "${projectName}",
short_name: "${projectName}",
description: "${projectName} - PWA Application",
theme_color: "#0c0c0c",
},
pwaAssets: { disabled: false, config: true },
devOptions: { enabled: true },
})`,
);
}
await tsProject.save();
}

View File

@@ -0,0 +1,89 @@
import path from "node:path";
import fs from "fs-extra";
import type { PackageManager, ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";
import { setupNuxtWorkersDeploy } from "./workers-nuxt-setup";
import { setupSvelteWorkersDeploy } from "./workers-svelte-setup";
import { setupWorkersVitePlugin } from "./workers-vite-setup";
export async function setupWebDeploy(config: ProjectConfig): Promise<void> {
const { webDeploy, frontend, projectDir } = config;
const { packageManager } = config;
if (webDeploy === "none") return;
if (webDeploy !== "workers") return;
const isNext = frontend.includes("next");
const isNuxt = frontend.includes("nuxt");
const isSvelte = frontend.includes("svelte");
const isTanstackRouter = frontend.includes("tanstack-router");
const isReactRouter = frontend.includes("react-router");
const isSolid = frontend.includes("solid");
if (isNext) {
await setupNextWorkersDeploy(projectDir, packageManager);
} else if (isNuxt) {
await setupNuxtWorkersDeploy(projectDir, packageManager);
} else if (isSvelte) {
await setupSvelteWorkersDeploy(projectDir, packageManager);
} else if (isTanstackRouter || isReactRouter || isSolid) {
await setupWorkersWebDeploy(projectDir, packageManager);
}
}
async function setupWorkersWebDeploy(
projectDir: string,
pkgManager: PackageManager,
): Promise<void> {
const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) {
return;
}
const packageJsonPath = path.join(webAppDir, "package.json");
if (await fs.pathExists(packageJsonPath)) {
const packageJson = await fs.readJson(packageJsonPath);
packageJson.scripts = {
...packageJson.scripts,
"wrangler:dev": "wrangler dev --port=3001",
deploy: `${pkgManager} run build && wrangler deploy`,
};
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
}
await setupWorkersVitePlugin(projectDir);
}
async function setupNextWorkersDeploy(
projectDir: string,
_packageManager: PackageManager,
): Promise<void> {
const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) return;
await addPackageDependency({
dependencies: ["@opennextjs/cloudflare"],
devDependencies: ["wrangler"],
projectDir: webAppDir,
});
const packageJsonPath = path.join(webAppDir, "package.json");
if (await fs.pathExists(packageJsonPath)) {
const pkg = await fs.readJson(packageJsonPath);
pkg.scripts = {
...pkg.scripts,
preview: "opennextjs-cloudflare build && opennextjs-cloudflare preview",
deploy: "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
upload: "opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen":
"wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
};
await fs.writeJson(packageJsonPath, pkg, { spaces: 2 });
}
}

View File

@@ -0,0 +1,112 @@
import path from "node:path";
import fs from "fs-extra";
import {
type ArrayLiteralExpression,
type CallExpression,
Node,
type ObjectLiteralExpression,
type PropertyAssignment,
SyntaxKind,
} from "ts-morph";
import type { PackageManager } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";
import { tsProject } from "../../utils/ts-morph";
export async function setupNuxtWorkersDeploy(
projectDir: string,
packageManager: PackageManager,
): Promise<void> {
const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) return;
await addPackageDependency({
devDependencies: ["nitro-cloudflare-dev", "wrangler"],
projectDir: webAppDir,
});
const pkgPath = path.join(webAppDir, "package.json");
if (await fs.pathExists(pkgPath)) {
const pkg = await fs.readJson(pkgPath);
pkg.scripts = {
...pkg.scripts,
deploy: `${packageManager} run build && wrangler deploy`,
"cf-typegen": "wrangler types",
};
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
}
const nuxtConfigPath = path.join(webAppDir, "nuxt.config.ts");
if (!(await fs.pathExists(nuxtConfigPath))) return;
const sourceFile = tsProject.addSourceFileAtPathIfExists(nuxtConfigPath);
if (!sourceFile) return;
const defineCall = sourceFile
.getDescendantsOfKind(SyntaxKind.CallExpression)
.find((expr) => {
const expression = expr.getExpression();
return (
Node.isIdentifier(expression) &&
expression.getText() === "defineNuxtConfig"
);
}) as CallExpression | undefined;
if (!defineCall) return;
const configObj = defineCall.getArguments()[0] as
| ObjectLiteralExpression
| undefined;
if (!configObj) return;
const today = new Date().toISOString().slice(0, 10);
const compatProp = configObj.getProperty("compatibilityDate");
if (compatProp && compatProp.getKind() === SyntaxKind.PropertyAssignment) {
(compatProp as PropertyAssignment).setInitializer(`'${today}'`);
} else {
configObj.addPropertyAssignment({
name: "compatibilityDate",
initializer: `'${today}'`,
});
}
const nitroInitializer = `{
preset: "cloudflare_module",
cloudflare: {
deployConfig: true,
nodeCompat: true
}
}`;
const nitroProp = configObj.getProperty("nitro");
if (nitroProp && nitroProp.getKind() === SyntaxKind.PropertyAssignment) {
(nitroProp as PropertyAssignment).setInitializer(nitroInitializer);
} else {
configObj.addPropertyAssignment({
name: "nitro",
initializer: nitroInitializer,
});
}
const modulesProp = configObj.getProperty("modules");
if (modulesProp && modulesProp.getKind() === SyntaxKind.PropertyAssignment) {
const arrayExpr = modulesProp.getFirstDescendantByKind(
SyntaxKind.ArrayLiteralExpression,
) as ArrayLiteralExpression | undefined;
if (arrayExpr) {
const alreadyHas = arrayExpr
.getElements()
.some(
(el) => el.getText().replace(/['"`]/g, "") === "nitro-cloudflare-dev",
);
if (!alreadyHas) arrayExpr.addElement("'nitro-cloudflare-dev'");
}
} else {
configObj.addPropertyAssignment({
name: "modules",
initializer: "['nitro-cloudflare-dev']",
});
}
await tsProject.save();
}

View File

@@ -0,0 +1,74 @@
import path from "node:path";
import fs from "fs-extra";
import type { ImportDeclaration } from "ts-morph";
import type { PackageManager } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";
import { tsProject } from "../../utils/ts-morph";
export async function setupSvelteWorkersDeploy(
projectDir: string,
packageManager: PackageManager,
): Promise<void> {
const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) return;
await addPackageDependency({
devDependencies: ["@sveltejs/adapter-cloudflare", "wrangler"],
projectDir: webAppDir,
});
const pkgPath = path.join(webAppDir, "package.json");
if (await fs.pathExists(pkgPath)) {
const pkg = await fs.readJson(pkgPath);
pkg.scripts = {
...pkg.scripts,
deploy: `${packageManager} run build && wrangler deploy`,
"cf-typegen": "wrangler types ./src/worker-configuration.d.ts",
};
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
}
const possibleConfigFiles = [
path.join(webAppDir, "svelte.config.js"),
path.join(webAppDir, "svelte.config.ts"),
];
const existingConfigPath = (
await Promise.all(
possibleConfigFiles.map(async (p) => ((await fs.pathExists(p)) ? p : "")),
)
).find((p) => p);
if (existingConfigPath) {
const sourceFile =
tsProject.addSourceFileAtPathIfExists(existingConfigPath);
if (!sourceFile) return;
const adapterImport = sourceFile
.getImportDeclarations()
.find((imp: ImportDeclaration) =>
["@sveltejs/adapter-auto", "@sveltejs/adapter-node"].includes(
imp.getModuleSpecifierValue(),
),
);
if (adapterImport) {
adapterImport.setModuleSpecifier("@sveltejs/adapter-cloudflare");
} else {
const alreadyHasCloudflare = sourceFile
.getImportDeclarations()
.some(
(imp) =>
imp.getModuleSpecifierValue() === "@sveltejs/adapter-cloudflare",
);
if (!alreadyHasCloudflare) {
sourceFile.insertImportDeclaration(0, {
defaultImport: "adapter",
moduleSpecifier: "@sveltejs/adapter-cloudflare",
});
}
}
await tsProject.save();
}
}

View File

@@ -0,0 +1,76 @@
import path from "node:path";
import fs from "fs-extra";
import {
type CallExpression,
Node,
type ObjectLiteralExpression,
SyntaxKind,
} from "ts-morph";
import { addPackageDependency } from "../../utils/add-package-deps";
import { ensureArrayProperty, tsProject } from "../../utils/ts-morph";
export async function setupWorkersVitePlugin(
projectDir: string,
): Promise<void> {
const webAppDir = path.join(projectDir, "apps/web");
const viteConfigPath = path.join(webAppDir, "vite.config.ts");
if (!(await fs.pathExists(viteConfigPath))) {
throw new Error("vite.config.ts not found in web app directory");
}
await addPackageDependency({
devDependencies: ["@cloudflare/vite-plugin", "wrangler"],
projectDir: webAppDir,
});
const sourceFile = tsProject.addSourceFileAtPathIfExists(viteConfigPath);
if (!sourceFile) {
throw new Error("vite.config.ts not found in web app directory");
}
const hasCloudflareImport = sourceFile
.getImportDeclarations()
.some((imp) => imp.getModuleSpecifierValue() === "@cloudflare/vite-plugin");
if (!hasCloudflareImport) {
sourceFile.insertImportDeclaration(0, {
namedImports: ["cloudflare"],
moduleSpecifier: "@cloudflare/vite-plugin",
});
}
const defineCall = sourceFile
.getDescendantsOfKind(SyntaxKind.CallExpression)
.find((expr) => {
const expression = expr.getExpression();
return (
Node.isIdentifier(expression) && expression.getText() === "defineConfig"
);
});
if (!defineCall) {
throw new Error("Could not find defineConfig call in vite config");
}
const callExpr = defineCall as CallExpression;
const configObject = callExpr.getArguments()[0] as
| ObjectLiteralExpression
| undefined;
if (!configObject) {
throw new Error("defineConfig argument is not an object literal");
}
const pluginsArray = ensureArrayProperty(configObject, "plugins");
const hasCloudflarePlugin = pluginsArray
.getElements()
.some((el) => el.getText().includes("cloudflare("));
if (!hasCloudflarePlugin) {
pluginsArray.addElement("cloudflare()");
}
await tsProject.save();
}

View File

@@ -18,6 +18,7 @@ import {
PackageManagerSchema,
ProjectNameSchema,
RuntimeSchema,
WebDeploySchema,
} from "./types";
import { getLatestCLIVersion } from "./utils/get-latest-cli-version";
import { openUrl } from "./utils/open-url";
@@ -55,6 +56,7 @@ const router = t.router({
backend: BackendSchema.optional(),
runtime: RuntimeSchema.optional(),
api: APISchema.optional(),
webDeploy: WebDeploySchema.optional(),
})
.optional()
.default({}),
@@ -70,19 +72,23 @@ const router = t.router({
}),
add: t.procedure
.meta({
description: "Add addons to an existing Better-T Stack project",
description:
"Add addons or deployment configurations to an existing Better-T Stack project",
})
.input(
z.tuple([
z
.object({
addons: z.array(AddonsSchema).optional().default([]),
webDeploy: WebDeploySchema.optional(),
projectDir: z.string().optional(),
install: z
.boolean()
.optional()
.default(false)
.describe("Install dependencies after adding addons"),
.describe(
"Install dependencies after adding addons or deployment",
),
packageManager: PackageManagerSchema.optional(),
})
.optional()

View File

@@ -136,7 +136,7 @@ export async function getAddonsToAdd(
const response = await multiselect<Addons>({
message: "Select addons",
options: options,
required: true,
required: false,
});
if (isCancel(response)) {

View File

@@ -12,6 +12,7 @@ import type {
PackageManager,
ProjectConfig,
Runtime,
WebDeploy,
} from "../types";
import { getAddonsChoice } from "./addons";
import { getApiChoice } from "./api";
@@ -26,6 +27,7 @@ import { getinstallChoice } from "./install";
import { getORMChoice } from "./orm";
import { getPackageManagerChoice } from "./package-manager";
import { getRuntimeChoice } from "./runtime";
import { getDeploymentChoice } from "./web-deploy";
type PromptGroupResults = {
frontend: Frontend[];
@@ -41,6 +43,7 @@ type PromptGroupResults = {
git: boolean;
packageManager: PackageManager;
install: boolean;
webDeploy: WebDeploy;
};
export async function gatherConfig(
@@ -87,6 +90,13 @@ export async function gatherConfig(
results.backend,
results.runtime,
),
webDeploy: ({ results }) =>
getDeploymentChoice(
flags.webDeploy,
results.runtime,
results.backend,
results.frontend,
),
git: () => getGitChoice(flags.git),
packageManager: () => getPackageManagerChoice(flags.packageManager),
install: () => getinstallChoice(flags.install),
@@ -107,6 +117,7 @@ export async function gatherConfig(
result.auth = false;
result.dbSetup = "none";
result.examples = ["todo"];
result.webDeploy = "none";
}
if (result.backend === "none") {
@@ -117,6 +128,7 @@ export async function gatherConfig(
result.auth = false;
result.dbSetup = "none";
result.examples = [];
result.webDeploy = "none";
}
return {
@@ -136,5 +148,6 @@ export async function gatherConfig(
install: result.install,
dbSetup: result.dbSetup,
api: result.api,
webDeploy: result.webDeploy,
};
}

View File

@@ -37,7 +37,7 @@ export async function getRuntimeChoice(
if (backend === "hono") {
runtimeOptions.push({
value: "workers",
label: "Cloudflare Workers (beta)",
label: "Cloudflare Workers",
hint: "Edge runtime on Cloudflare's global network",
});
}

View File

@@ -0,0 +1,122 @@
import { cancel, isCancel, select } from "@clack/prompts";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants";
import type { Backend, Frontend, Runtime, WebDeploy } from "../types";
const WORKERS_COMPATIBLE_FRONTENDS: Frontend[] = [
"tanstack-router",
"react-router",
"solid",
"next",
"nuxt",
"svelte",
];
type DeploymentOption = {
value: WebDeploy;
label: string;
hint: string;
};
function getDeploymentDisplay(deployment: WebDeploy): {
label: string;
hint: string;
} {
if (deployment === "workers") {
return {
label: "Cloudflare Workers",
hint: "Deploy to Cloudflare Workers using Wrangler",
};
}
return {
label: deployment,
hint: `Add ${deployment} deployment`,
};
}
export async function getDeploymentChoice(
deployment?: WebDeploy,
_runtime?: Runtime,
_backend?: Backend,
frontend: Frontend[] = [],
): Promise<WebDeploy> {
if (deployment !== undefined) return deployment;
const hasCompatibleFrontend = frontend.some((f) =>
WORKERS_COMPATIBLE_FRONTENDS.includes(f),
);
if (!hasCompatibleFrontend) {
return "none";
}
const options: DeploymentOption[] = [
{
value: "workers",
label: "Cloudflare Workers",
hint: "Deploy to Cloudflare Workers using Wrangler",
},
{ value: "none", label: "None", hint: "Manual setup" },
];
const response = await select<WebDeploy>({
message: "Select web deployment",
options,
initialValue: DEFAULT_CONFIG.webDeploy,
});
if (isCancel(response)) {
cancel(pc.red("Operation cancelled"));
process.exit(0);
}
return response;
}
export async function getDeploymentToAdd(
frontend: Frontend[],
existingDeployment?: WebDeploy,
): Promise<WebDeploy> {
const options: DeploymentOption[] = [];
if (
frontend.some((f) => WORKERS_COMPATIBLE_FRONTENDS.includes(f)) &&
existingDeployment !== "workers"
) {
const { label, hint } = getDeploymentDisplay("workers");
options.push({
value: "workers",
label,
hint,
});
}
if (existingDeployment && existingDeployment !== "none") {
return "none";
}
if (options.length > 0) {
options.push({
value: "none",
label: "None",
hint: "Skip deployment setup",
});
}
if (options.length === 0) {
return "none";
}
const response = await select<WebDeploy>({
message: "Select web deployment",
options,
initialValue: DEFAULT_CONFIG.webDeploy,
});
if (isCancel(response)) {
cancel(pc.red("Operation cancelled"));
process.exit(0);
}
return response;
}

View File

@@ -92,6 +92,11 @@ export const ProjectNameSchema = z
.describe("Project name or path");
export type ProjectName = z.infer<typeof ProjectNameSchema>;
export const WebDeploySchema = z
.enum(["workers", "none"])
.describe("Web deployment");
export type WebDeploy = z.infer<typeof WebDeploySchema>;
export type CreateInput = {
projectName?: string;
yes?: boolean;
@@ -108,10 +113,12 @@ export type CreateInput = {
backend?: Backend;
runtime?: Runtime;
api?: API;
webDeploy?: WebDeploy;
};
export type AddInput = {
addons?: Addons[];
webDeploy?: WebDeploy;
projectDir?: string;
install?: boolean;
packageManager?: PackageManager;
@@ -138,6 +145,7 @@ export interface ProjectConfig {
install: boolean;
dbSetup: DatabaseSetup;
api: API;
webDeploy: WebDeploy;
}
export interface BetterTStackConfig {
@@ -154,6 +162,7 @@ export interface BetterTStackConfig {
packageManager: PackageManager;
dbSetup: DatabaseSetup;
api: API;
webDeploy: WebDeploy;
}
export type AvailablePackageManagers = "npm" | "pnpm" | "bun";

View File

@@ -23,6 +23,7 @@ export async function writeBtsConfig(
packageManager: projectConfig.packageManager,
dbSetup: projectConfig.dbSetup,
api: projectConfig.api,
webDeploy: projectConfig.webDeploy,
};
const baseContent = {
@@ -40,6 +41,7 @@ export async function writeBtsConfig(
packageManager: btsConfig.packageManager,
dbSetup: btsConfig.dbSetup,
api: btsConfig.api,
webDeploy: btsConfig.webDeploy,
};
let configContent = JSON.stringify(baseContent);
@@ -91,7 +93,7 @@ export async function readBtsConfig(
export async function updateBtsConfig(
projectDir: string,
updates: Partial<Pick<BetterTStackConfig, "addons">>,
updates: Partial<Pick<BetterTStackConfig, "addons" | "webDeploy">>,
): Promise<void> {
try {
const configPath = path.join(projectDir, BTS_CONFIG_FILE);

View File

@@ -101,6 +101,12 @@ export function displayConfig(config: Partial<ProjectConfig>) {
);
}
if (config.webDeploy !== undefined) {
configDisplay.push(
`${pc.blue("Web Deployment:")} ${String(config.webDeploy)}`,
);
}
if (configDisplay.length === 0) {
return pc.yellow("No configuration selected.");
}

View File

@@ -29,6 +29,7 @@ export function generateReproducibleCommand(config: ProjectConfig): string {
}
flags.push(`--db-setup ${config.dbSetup}`);
flags.push(`--web-deploy ${config.webDeploy}`);
flags.push(config.git ? "--git" : "--no-git");
flags.push(`--package-manager ${config.packageManager}`);
flags.push(config.install ? "--install" : "--no-install");

View File

@@ -0,0 +1,31 @@
import {
type ArrayLiteralExpression,
IndentationText,
type ObjectLiteralExpression,
Project,
QuoteKind,
SyntaxKind,
} from "ts-morph";
export const tsProject = new Project({
useInMemoryFileSystem: false,
skipAddingFilesFromTsConfig: true,
manipulationSettings: {
quoteKind: QuoteKind.Single,
indentationText: IndentationText.TwoSpaces,
},
});
export function ensureArrayProperty(
obj: ObjectLiteralExpression,
name: string,
): ArrayLiteralExpression {
return (obj
.getProperty(name)
?.getFirstDescendantByKind(SyntaxKind.ArrayLiteralExpression) ??
obj
.addPropertyAssignment({ name, initializer: "[]" })
.getFirstDescendantByKindOrThrow(
SyntaxKind.ArrayLiteralExpression,
)) as ArrayLiteralExpression;
}

View File

@@ -14,6 +14,7 @@ import {
type ProjectConfig,
ProjectNameSchema,
type Runtime,
type WebDeploy,
} from "./types";
export function processAndValidateFlags(
@@ -82,6 +83,10 @@ export function processAndValidateFlags(
config.packageManager = options.packageManager as PackageManager;
}
if (options.webDeploy) {
config.webDeploy = options.webDeploy as WebDeploy;
}
if (projectName) {
const result = ProjectNameSchema.safeParse(path.basename(projectName));
if (!result.success) {
@@ -446,6 +451,24 @@ export function processAndValidateFlags(
process.exit(1);
}
if (
config.webDeploy === "workers" &&
config.frontend &&
config.frontend.length > 0
) {
const incompatibleFrontends = config.frontend.filter(
(f) => f === "tanstack-start",
);
if (incompatibleFrontends.length > 0) {
consola.fatal(
`The following frontends are not compatible with '--web-deploy workers': ${incompatibleFrontends.join(
", ",
)}. Please choose a different frontend or remove '--web-deploy workers'.`,
);
process.exit(1);
}
}
return config;
}

View File

@@ -0,0 +1,51 @@
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "{{projectName}}",
"main": "./.output/server/index.mjs",
"compatibility_date": "2025-07-01",
"assets": {
"binding": "ASSETS",
"directory": "./.output/public/"
},
"observability": {
"enabled": true
}
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" },
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
// "vars": { "MY_VARIABLE": "production_value" },
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
/**
* Static Assets
* https://developers.cloudflare.com/workers/static-assets/binding/
*/
// "assets": { "directory": "./public/", "binding": "ASSETS" },
/**
* Service Bindings (communicate between multiple Workers)
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
}

View File

@@ -0,0 +1,6 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config";
// import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
export default defineCloudflareConfig({
// incrementalCache: r2IncrementalCache,
});

View File

@@ -0,0 +1,22 @@
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "{{projectName}}",
"compatibility_date": "2025-07-05",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
// "r2_buckets": [
// // Use R2 incremental cache
// // See https://opennext.js.org/cloudflare/caching
// {
// "binding": "NEXT_INC_CACHE_R2_BUCKET",
// // Create the bucket before deploying
// // You can change the bucket name if you want
// // See https://developers.cloudflare.com/workers/wrangler/commands/#r2-bucket-create
// "bucket_name": "cache"
// }
// ]
}

View File

@@ -0,0 +1,8 @@
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "{{projectName}}",
"compatibility_date": "2025-04-03",
"assets": {
"not_found_handling": "single-page-application"
}
}

View File

@@ -0,0 +1,8 @@
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "{{projectName}}",
"compatibility_date": "2025-04-03",
"assets": {
"not_found_handling": "single-page-application"
}
}

View File

@@ -0,0 +1,8 @@
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "{{projectName}}",
"compatibility_date": "2025-04-03",
"assets": {
"not_found_handling": "single-page-application"
}
}

View File

@@ -0,0 +1,51 @@
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "{{projectName}}",
"main": ".svelte-kit/cloudflare/_worker.js",
"compatibility_date": "2025-07-05",
"assets": {
"binding": "ASSETS",
"directory": ".svelte-kit/cloudflare"
},
"observability": {
"enabled": true
}
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" },
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
// "vars": { "MY_VARIABLE": "production_value" },
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
/**
* Static Assets
* https://developers.cloudflare.com/workers/static-assets/binding/
*/
// "assets": { "directory": "./public/", "binding": "ASSETS" },
/**
* Service Bindings (communicate between multiple Workers)
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
}

View File

@@ -1,6 +1,3 @@
{{#if (includes addons "pwa")}}
import { VitePWA } from "vite-plugin-pwa";
{{/if}}
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
@@ -11,23 +8,5 @@ export default defineConfig({
tailwindcss(),
reactRouter(),
tsconfigPaths(),
{{#if (includes addons "pwa")}}
VitePWA({
registerType: "autoUpdate",
manifest: {
name: "{{projectName}}",
short_name: "{{projectName}}",
description: "{{projectName}} - PWA Application",
theme_color: "#0c0c0c",
},
pwaAssets: {
disabled: false,
config: true,
},
devOptions: {
enabled: true,
},
}),
{{/if}}
],
});
});

View File

@@ -1,8 +1,5 @@
{{#if (includes addons "pwa")}}
import { VitePWA } from "vite-plugin-pwa";
{{/if}}
import tailwindcss from "@tailwindcss/vite";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
import { defineConfig } from "vite";
@@ -10,30 +7,12 @@ import { defineConfig } from "vite";
export default defineConfig({
plugins: [
tailwindcss(),
TanStackRouterVite({}),
tanstackRouter({}),
react(),
{{#if (includes addons "pwa")}}
VitePWA({
registerType: "autoUpdate",
manifest: {
name: "{{projectName}}",
short_name: "{{projectName}}",
description: "{{projectName}} - PWA Application",
theme_color: "#0c0c0c",
},
pwaAssets: {
disabled: false,
config: true,
},
devOptions: {
enabled: true,
},
}),
{{/if}}
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
});

View File

@@ -17,14 +17,14 @@
"@tanstack/react-start": "^1.121.0-alpha.27",
"@tanstack/router-plugin": "^1.121.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.473.0",
"clsx": "^2.1.1",
"lucide-react": "^0.525.0",
"next-themes": "^0.4.6",
"react": "19.0.0",
"react-dom": "19.0.0",
"sonner": "^2.0.3",
"tailwindcss": "^4.1.3",
"tailwind-merge": "^2.6.0",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.2.5",
"vite-tsconfig-paths": "^5.1.4",
"zod": "^3.25.16"
@@ -38,7 +38,7 @@
"@vitejs/plugin-react": "^4.5.2",
"jsdom": "^26.0.0",
"typescript": "^5.7.2",
"vite": "^6.3.5",
"web-vitals": "^4.2.4"
"vite": "^7.0.2",
"web-vitals": "^5.0.3"
}
}
}

View File

@@ -50,3 +50,8 @@ next-env.d.ts
# Other
dev-dist
.wrangler
.dev.vars*
.open-next

View File

@@ -5,3 +5,6 @@ dist-ssr
*.local
.env
.env.*
.wrangler
.dev.vars*

View File

@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"dev": "vite --port 3001",
"build": "vite build && tsc",
"build": "vite build",
"serve": "vite preview",
"test": "vitest run"
},
@@ -21,7 +21,7 @@
},
"devDependencies": {
"typescript": "^5.7.2",
"vite": "^6.0.11",
"vite": "^7.0.2",
"vite-plugin-solid": "^2.11.2"
}
}

View File

@@ -4,7 +4,7 @@ import { routeTree } from "./routeTree.gen";
import "./styles.css";
{{#if (eq api "orpc")}}
import { QueryClientProvider } from "@tanstack/solid-query";
import { queryClient } from "./utils/orpc";
import { orpc, queryClient } from "./utils/orpc";
{{/if}}
const router = createRouter({
@@ -12,6 +12,9 @@ const router = createRouter({
defaultPreload: "intent",
scrollRestoration: true,
defaultPreloadStaleTime: 0,
{{#if (eq api "orpc")}}
context: { orpc, queryClient },
{{/if}}
});
declare module "@tanstack/solid-router" {

View File

@@ -1,39 +0,0 @@
import { defineConfig } from "vite";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import solidPlugin from "vite-plugin-solid";
import tailwindcss from "@tailwindcss/vite";
import path from "node:path";
{{#if (includes addons "pwa")}}
import { VitePWA } from "vite-plugin-pwa";
{{/if}}
export default defineConfig({
plugins: [
TanStackRouterVite({ target: "solid", autoCodeSplitting: true }),
solidPlugin(),
tailwindcss(),
{{#if (includes addons "pwa")}}
VitePWA({
registerType: "autoUpdate",
manifest: {
name: "{{projectName}}",
short_name: "{{projectName}}",
description: "{{projectName}} - PWA Application",
theme_color: "#0c0c0c",
},
pwaAssets: {
disabled: false,
config: true,
},
devOptions: {
enabled: true,
},
}),
{{/if}}
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});

View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import solidPlugin from "vite-plugin-solid";
import tailwindcss from "@tailwindcss/vite";
import path from "node:path";
export default defineConfig({
plugins: [
tanstackRouter({ target: "solid", autoCodeSplitting: true }),
solidPlugin(),
tailwindcss(),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});

View File

@@ -21,7 +21,7 @@
"tailwindcss": "^4.1.4",
"typescript": "^5.8.3",
"@tanstack/svelte-query-devtools": "^5.74.6",
"vite": "^6.3.3"
"vite": "^7.0.2"
},
"dependencies": {
"@tanstack/svelte-form": "^1.7.0",