diff --git a/apps/cli/src/helpers/project-generation/template-manager.ts b/apps/cli/src/helpers/project-generation/template-manager.ts index 2f4c5127..c6b68d39 100644 --- a/apps/cli/src/helpers/project-generation/template-manager.ts +++ b/apps/cli/src/helpers/project-generation/template-manager.ts @@ -831,6 +831,38 @@ export async function handleExtras( ); } } + + if (context.runtime === "vercel-edge") { + const runtimeVercelEdgeDir = path.join( + PKG_ROOT, + "templates/runtime/vercel-edge", + ); + if (await fs.pathExists(runtimeVercelEdgeDir)) { + await processAndCopyFiles( + "**/*", + runtimeVercelEdgeDir, + projectDir, + context, + false, + ); + } + } + + if (context.runtime === "vercel-nodejs") { + const runtimeVercelNodejsDir = path.join( + PKG_ROOT, + "templates/runtime/vercel-nodejs", + ); + if (await fs.pathExists(runtimeVercelNodejsDir)) { + await processAndCopyFiles( + "**/*", + runtimeVercelNodejsDir, + projectDir, + context, + false, + ); + } + } } export async function setupDockerComposeTemplates( diff --git a/apps/cli/src/helpers/setup/backend-setup.ts b/apps/cli/src/helpers/setup/backend-setup.ts index e7afa256..88ee1857 100644 --- a/apps/cli/src/helpers/setup/backend-setup.ts +++ b/apps/cli/src/helpers/setup/backend-setup.ts @@ -24,7 +24,7 @@ export async function setupBackendDependencies( dependencies.push("@hono/trpc-server"); } - if (runtime === "node") { + if (runtime === "node" || runtime === "vercel-nodejs") { dependencies.push("@hono/node-server"); devDependencies.push("tsx", "@types/node"); } diff --git a/apps/cli/src/helpers/setup/runtime-setup.ts b/apps/cli/src/helpers/setup/runtime-setup.ts index 5c6f36a4..bd3a3f1d 100644 --- a/apps/cli/src/helpers/setup/runtime-setup.ts +++ b/apps/cli/src/helpers/setup/runtime-setup.ts @@ -25,6 +25,10 @@ export async function setupRuntime(config: ProjectConfig): Promise { await setupNodeRuntime(serverDir, backend); } else if (runtime === "workers") { await setupWorkersRuntime(serverDir); + } else if (runtime === "vercel-edge") { + await setupVercelEdgeRuntime(serverDir); + } else if (runtime === "vercel-nodejs") { + await setupVercelNodejsRuntime(serverDir); } } @@ -145,3 +149,40 @@ async function setupWorkersRuntime(serverDir: string): Promise { projectDir: serverDir, }); } + +async function setupVercelEdgeRuntime(serverDir: string): Promise { + const packageJsonPath = path.join(serverDir, "package.json"); + if (!(await fs.pathExists(packageJsonPath))) return; + + const packageJson = await fs.readJson(packageJsonPath); + + packageJson.scripts = { + ...packageJson.scripts, + dev: "next dev", + build: "next build", + start: "next start", + }; + + await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); +} + +async function setupVercelNodejsRuntime(serverDir: string): Promise { + const packageJsonPath = path.join(serverDir, "package.json"); + if (!(await fs.pathExists(packageJsonPath))) return; + + const packageJson = await fs.readJson(packageJsonPath); + + packageJson.scripts = { + ...packageJson.scripts, + dev: "next dev", + build: "next build", + start: "next start", + }; + + await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); + + await addPackageDependency({ + dependencies: ["@hono/node-server"], + projectDir: serverDir, + }); +} diff --git a/apps/cli/src/prompts/runtime.ts b/apps/cli/src/prompts/runtime.ts index 2a73ab9b..4ea2bb52 100644 --- a/apps/cli/src/prompts/runtime.ts +++ b/apps/cli/src/prompts/runtime.ts @@ -40,6 +40,16 @@ export async function getRuntimeChoice( label: "Cloudflare Workers", hint: "Edge runtime on Cloudflare's global network", }); + runtimeOptions.push({ + value: "vercel-edge", + label: "Vercel Edge Runtime (beta)", + hint: "Edge runtime on Vercel's global network", + }); + runtimeOptions.push({ + value: "vercel-nodejs", + label: "Vercel Node.js Runtime (beta)", + hint: "Node.js runtime optimized for Vercel", + }); } const response = await select({ diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index 54e34c57..8594e61d 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -16,9 +16,9 @@ export const BackendSchema = z export type Backend = z.infer; export const RuntimeSchema = z - .enum(["bun", "node", "workers", "none"]) + .enum(["bun", "node", "workers", "vercel-edge", "vercel-nodejs", "none"]) .describe( - "Runtime environment (workers only available with hono backend and drizzle orm)", + "Runtime environment (workers, vercel-edge, and vercel-nodejs only available with hono backend and drizzle orm)", ); export type Runtime = z.infer; diff --git a/apps/cli/src/validation.ts b/apps/cli/src/validation.ts index 607b3478..fb82d063 100644 --- a/apps/cli/src/validation.ts +++ b/apps/cli/src/validation.ts @@ -399,6 +399,19 @@ export function processAndValidateFlags( process.exit(1); } + if ( + providedFlags.has("runtime") && + (options.runtime === "vercel-edge" || + options.runtime === "vercel-nodejs") && + config.backend && + config.backend !== "hono" + ) { + consola.fatal( + `Vercel runtime (--runtime ${options.runtime}) is only supported with Hono backend (--backend hono). Current backend: ${config.backend}. Please use '--backend hono' or choose a different runtime.`, + ); + process.exit(1); + } + if ( providedFlags.has("backend") && config.backend && @@ -411,6 +424,18 @@ export function processAndValidateFlags( process.exit(1); } + if ( + providedFlags.has("backend") && + config.backend && + config.backend !== "hono" && + (config.runtime === "vercel-edge" || config.runtime === "vercel-nodejs") + ) { + consola.fatal( + `Backend '${config.backend}' is not compatible with Vercel runtime. Vercel runtime is only supported with Hono backend. Please use '--backend hono' or choose a different runtime.`, + ); + process.exit(1); + } + if ( providedFlags.has("runtime") && options.runtime === "workers" && @@ -646,6 +671,17 @@ export function validateConfigCompatibility( process.exit(1); } } + + if ( + (effectiveRuntime === "vercel-edge" || + effectiveRuntime === "vercel-nodejs") && + effectiveBackend !== "hono" + ) { + consola.fatal( + `Vercel runtime is only supported with Hono backend. Current backend: ${effectiveBackend}. Please use a different runtime or change to Hono backend.`, + ); + process.exit(1); + } } export function getProvidedFlags(options: CLIInput): Set { diff --git a/apps/cli/templates/backend/server/elysia/src/index.ts.hbs b/apps/cli/templates/backend/server/elysia/src/index.ts.hbs index dfdf3c85..39e4b6a2 100644 --- a/apps/cli/templates/backend/server/elysia/src/index.ts.hbs +++ b/apps/cli/templates/backend/server/elysia/src/index.ts.hbs @@ -1,4 +1,4 @@ -import "dotenv/config"; +import env from "@/env"; {{#if (eq runtime "node")}} import { node } from "@elysiajs/node"; {{/if}} @@ -29,7 +29,7 @@ const app = new Elysia() {{/if}} .use( cors({ - origin: process.env.CORS_ORIGIN || "", + origin: env.CORS_ORIGIN || "", methods: ["GET", "POST", "OPTIONS"], {{#if auth}} allowedHeaders: ["Content-Type", "Authorization"], diff --git a/apps/cli/templates/backend/server/express/src/index.ts.hbs b/apps/cli/templates/backend/server/express/src/index.ts.hbs index da6fd121..64067753 100644 --- a/apps/cli/templates/backend/server/express/src/index.ts.hbs +++ b/apps/cli/templates/backend/server/express/src/index.ts.hbs @@ -1,4 +1,4 @@ -import "dotenv/config"; +import env from "@/env"; {{#if (eq api "trpc")}} import { createExpressMiddleware } from "@trpc/server/adapters/express"; import { createContext } from "./lib/context"; @@ -26,7 +26,7 @@ const app = express(); app.use( cors({ - origin: process.env.CORS_ORIGIN || "", + origin: env.CORS_ORIGIN || "", methods: ["GET", "POST", "OPTIONS"], {{#if auth}} allowedHeaders: ["Content-Type", "Authorization"], diff --git a/apps/cli/templates/backend/server/fastify/src/index.ts.hbs b/apps/cli/templates/backend/server/fastify/src/index.ts.hbs index 6b0d2f7b..8c12b071 100644 --- a/apps/cli/templates/backend/server/fastify/src/index.ts.hbs +++ b/apps/cli/templates/backend/server/fastify/src/index.ts.hbs @@ -1,4 +1,4 @@ -import "dotenv/config"; +import env from "@/env"; import Fastify from "fastify"; import fastifyCors from "@fastify/cors"; @@ -29,7 +29,7 @@ import { auth } from "./lib/auth"; {{/if}} const baseCorsConfig = { - origin: process.env.CORS_ORIGIN || "", + origin: env.CORS_ORIGIN || "", methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], allowedHeaders: [ "Content-Type", @@ -44,7 +44,7 @@ const baseCorsConfig = { const handler = new RPCHandler(appRouter, { plugins: [ new CORSPlugin({ - origin: process.env.CORS_ORIGIN, + origin: env.CORS_ORIGIN, credentials: true, allowHeaders: ["Content-Type", "Authorization"], }), diff --git a/apps/cli/templates/backend/server/hono/src/index.ts.hbs b/apps/cli/templates/backend/server/hono/src/index.ts.hbs index 0fc09ae6..0da22027 100644 --- a/apps/cli/templates/backend/server/hono/src/index.ts.hbs +++ b/apps/cli/templates/backend/server/hono/src/index.ts.hbs @@ -1,5 +1,5 @@ -{{#if (or (eq runtime "bun") (eq runtime "node"))}} -import "dotenv/config"; +{{#if (or (eq runtime "bun") (eq runtime "node") (eq runtime "vercel-edge") (eq runtime "vercel-nodejs"))}} +import env from "@/env"; {{/if}} {{#if (eq runtime "workers")}} import { env } from "cloudflare:workers"; @@ -20,7 +20,7 @@ import { auth } from "./lib/auth"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { logger } from "hono/logger"; -{{#if (and (includes examples "ai") (or (eq runtime "bun") (eq runtime "node")))}} +{{#if (and (includes examples "ai") (or (eq runtime "bun") (eq runtime "node") (eq runtime "vercel-edge") (eq runtime "vercel-nodejs")))}} import { streamText } from "ai"; import { google } from "@ai-sdk/google"; import { stream } from "hono/streaming"; @@ -35,8 +35,8 @@ const app = new Hono(); app.use(logger()); app.use("/*", cors({ - {{#if (or (eq runtime "bun") (eq runtime "node"))}} - origin: process.env.CORS_ORIGIN || "", + {{#if (or (eq runtime "bun") (eq runtime "node") (eq runtime "vercel-edge") (eq runtime "vercel-nodejs"))}} + origin: env.CORS_ORIGIN || "", {{/if}} {{#if (eq runtime "workers")}} origin: env.CORS_ORIGIN || "", @@ -77,7 +77,7 @@ app.use("/trpc/*", trpcServer({ })); {{/if}} -{{#if (and (includes examples "ai") (or (eq runtime "bun") (eq runtime "node")))}} +{{#if (and (includes examples "ai") (or (eq runtime "bun") (eq runtime "node") (eq runtime "vercel-edge") (eq runtime "vercel-nodejs")))}} app.post("/ai", async (c) => { const body = await c.req.json(); const messages = body.messages || []; @@ -128,6 +128,12 @@ serve({ export default app; {{/if}} {{#if (eq runtime "workers")}} +export default app; + {{/if}} + {{#if (eq runtime "vercel-edge")}} +export default app; + {{/if}} + {{#if (eq runtime "vercel-nodejs")}} export default app; {{/if}} {{/if}} diff --git a/apps/cli/templates/backend/server/server-base/src/env.ts.hbs b/apps/cli/templates/backend/server/server-base/src/env.ts.hbs new file mode 100644 index 00000000..65d345ce --- /dev/null +++ b/apps/cli/templates/backend/server/server-base/src/env.ts.hbs @@ -0,0 +1,23 @@ +import "dotenv/config"; +import { z } from "zod"; + +const EnvSchema = z.object({ + CORS_ORIGIN: z.string().url(), + DATABASE_URL: z.string().url(), + {{#if (eq dbSetup "turso")}} + DATABASE_AUTH_TOKEN: z.string().optional(), + {{/if}} +}) + +export type env = z.infer; + +// eslint-disable-next-line ts/no-redeclare +const { data: env, error } = EnvSchema.safeParse(process.env); + +if (error) { + console.error("❌ Invalid env:"); + console.error(JSON.stringify(error.flatten().fieldErrors, null, 2)); + process.exit(1); +} + +export default env! diff --git a/apps/cli/templates/backend/server/server-base/tsconfig.json.hbs b/apps/cli/templates/backend/server/server-base/tsconfig.json.hbs index 169fe4fb..957c4b81 100644 --- a/apps/cli/templates/backend/server/server-base/tsconfig.json.hbs +++ b/apps/cli/templates/backend/server/server-base/tsconfig.json.hbs @@ -22,6 +22,8 @@ {{else if (eq runtime "workers")}} "./worker-configuration", "node" + {{else if (or (eq runtime "vercel-edge") (eq runtime "vercel-nodejs"))}} + "node" {{else}} "node", "bun" diff --git a/apps/cli/templates/db/drizzle/mysql/src/db/index.ts.hbs b/apps/cli/templates/db/drizzle/mysql/src/db/index.ts.hbs index b51809aa..37b77313 100644 --- a/apps/cli/templates/db/drizzle/mysql/src/db/index.ts.hbs +++ b/apps/cli/templates/db/drizzle/mysql/src/db/index.ts.hbs @@ -1,20 +1,16 @@ -{{#if (or (eq runtime "bun") (eq runtime "node"))}} import { drizzle } from "drizzle-orm/mysql2"; - -export const db = drizzle({ - connection: { - uri: process.env.DATABASE_URL, - }, -}); +{{#if (or (eq runtime "bun") (eq runtime "node") (eq runtime "vercel-edge") (eq runtime "vercel-nodejs"))}} +import env from "../../env"; {{/if}} {{#if (eq runtime "workers")}} -import { drizzle } from "drizzle-orm/mysql2"; import { env } from "cloudflare:workers"; +{{/if}} + + export const db = drizzle({ connection: { uri: env.DATABASE_URL, }, }); -{{/if}} diff --git a/apps/cli/templates/db/drizzle/postgres/drizzle.config.ts.hbs b/apps/cli/templates/db/drizzle/postgres/drizzle.config.ts.hbs index c1775ea7..4519b275 100644 --- a/apps/cli/templates/db/drizzle/postgres/drizzle.config.ts.hbs +++ b/apps/cli/templates/db/drizzle/postgres/drizzle.config.ts.hbs @@ -1,10 +1,16 @@ import { defineConfig } from "drizzle-kit"; +{{#if (eq runtime "workers")}} + import { env } from "cloudflare:workers"; +{{else}} + import env from "@/env"; +{{/if}} + export default defineConfig({ schema: "./src/db/schema", out: "./src/db/migrations", dialect: "postgresql", dbCredentials: { - url: process.env.DATABASE_URL || "", + url: env.DATABASE_URL, }, }); diff --git a/apps/cli/templates/db/drizzle/postgres/src/db/index.ts.hbs b/apps/cli/templates/db/drizzle/postgres/src/db/index.ts.hbs index 732bdd73..7463073c 100644 --- a/apps/cli/templates/db/drizzle/postgres/src/db/index.ts.hbs +++ b/apps/cli/templates/db/drizzle/postgres/src/db/index.ts.hbs @@ -1,29 +1,22 @@ -{{#if (or (eq runtime "bun") (eq runtime "node"))}} +{{!-- Import database libraries based on setup type --}} {{#if (eq dbSetup "neon")}} -import { neon } from '@neondatabase/serverless'; -import { drizzle } from 'drizzle-orm/neon-http'; - -const sql = neon(process.env.DATABASE_URL || ""); -export const db = drizzle(sql); + import { neon } from '@neondatabase/serverless'; + import { drizzle } from 'drizzle-orm/neon-http'; {{else}} -import { drizzle } from "drizzle-orm/node-postgres"; - -export const db = drizzle(process.env.DATABASE_URL || ""); -{{/if}} + import { drizzle } from "drizzle-orm/node-postgres"; {{/if}} +{{!-- Import environment variables based on runtime --}} {{#if (eq runtime "workers")}} -{{#if (eq dbSetup "neon")}} -import { neon } from '@neondatabase/serverless'; -import { drizzle } from 'drizzle-orm/neon-http'; -import { env } from "cloudflare:workers"; - -const sql = neon(env.DATABASE_URL || ""); -export const db = drizzle(sql); + import { env } from "cloudflare:workers"; {{else}} -import { drizzle } from "drizzle-orm/node-postgres"; -import { env } from "cloudflare:workers"; - -export const db = drizzle(env.DATABASE_URL || ""); + import env from "@/env"; {{/if}} + +{{!-- Database setup based on setup type --}} +{{#if (eq dbSetup "neon")}} + const sql = neon(env.DATABASE_URL); + export const db = drizzle(sql); +{{else}} + export const db = drizzle(env.DATABASE_URL); {{/if}} diff --git a/apps/cli/templates/db/drizzle/sqlite/src/db/index.ts.hbs b/apps/cli/templates/db/drizzle/sqlite/src/db/index.ts.hbs index 62325d1d..8236db46 100644 --- a/apps/cli/templates/db/drizzle/sqlite/src/db/index.ts.hbs +++ b/apps/cli/templates/db/drizzle/sqlite/src/db/index.ts.hbs @@ -1,11 +1,12 @@ -{{#if (or (eq runtime "bun") (eq runtime "node"))}} +{{#if (or (eq runtime "bun") (eq runtime "node") (eq runtime "vercel-edge") (eq runtime "vercel-nodejs"))}} import { drizzle } from "drizzle-orm/libsql"; import { createClient } from "@libsql/client"; +import env from "../../env"; const client = createClient({ - url: process.env.DATABASE_URL || "", + url: env.DATABASE_URL || "", {{#if (eq dbSetup "turso")}} - authToken: process.env.DATABASE_AUTH_TOKEN, + authToken: env.DATABASE_AUTH_TOKEN, {{/if}} }); diff --git a/apps/cli/templates/runtime/vercel-edge/apps/server/api/index.ts b/apps/cli/templates/runtime/vercel-edge/apps/server/api/index.ts new file mode 100644 index 00000000..4f0110b0 --- /dev/null +++ b/apps/cli/templates/runtime/vercel-edge/apps/server/api/index.ts @@ -0,0 +1,16 @@ +import { handle } from "hono/vercel"; + +// eslint-disable-next-line ts/ban-ts-comment +// @ts-expect-error +// eslint-disable-next-line antfu/no-import-dist +import app from "../dist/src/index.js"; + +export const runtime = "edge"; + +export const GET = handle(app); +export const POST = handle(app); +export const PUT = handle(app); +export const PATCH = handle(app); +export const DELETE = handle(app); +export const HEAD = handle(app); +export const OPTIONS = handle(app); diff --git a/apps/cli/templates/runtime/vercel-edge/apps/server/api/vercel.json b/apps/cli/templates/runtime/vercel-edge/apps/server/api/vercel.json new file mode 100644 index 00000000..18da4e3c --- /dev/null +++ b/apps/cli/templates/runtime/vercel-edge/apps/server/api/vercel.json @@ -0,0 +1,3 @@ +{ + "rewrites": [{ "source": "/(.*)", "destination": "/api" }] +} diff --git a/apps/cli/templates/runtime/vercel-nodejs/apps/server/api/index.ts b/apps/cli/templates/runtime/vercel-nodejs/apps/server/api/index.ts new file mode 100644 index 00000000..6c35ab38 --- /dev/null +++ b/apps/cli/templates/runtime/vercel-nodejs/apps/server/api/index.ts @@ -0,0 +1,8 @@ +import { handle } from "@hono/node-server/vercel"; + +// eslint-disable-next-line ts/ban-ts-comment +// @ts-expect-error +// eslint-disable-next-line antfu/no-import-dist +import app from "../dist/src/index.js"; + +export default handle(app); diff --git a/apps/cli/templates/runtime/vercel-nodejs/apps/server/api/vercel.json b/apps/cli/templates/runtime/vercel-nodejs/apps/server/api/vercel.json new file mode 100644 index 00000000..18da4e3c --- /dev/null +++ b/apps/cli/templates/runtime/vercel-nodejs/apps/server/api/vercel.json @@ -0,0 +1,3 @@ +{ + "rewrites": [{ "source": "/(.*)", "destination": "/api" }] +} diff --git a/apps/web/src/app/(home)/_components/stack-builder.tsx b/apps/web/src/app/(home)/_components/stack-builder.tsx index 15bddc63..95e11fec 100644 --- a/apps/web/src/app/(home)/_components/stack-builder.tsx +++ b/apps/web/src/app/(home)/_components/stack-builder.tsx @@ -707,6 +707,32 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => { } } + if ( + nextStack.runtime === "vercel-edge" || + nextStack.runtime === "vercel-nodejs" + ) { + if (nextStack.backend !== "hono") { + const runtimeName = + nextStack.runtime === "vercel-edge" + ? "Vercel Edge" + : "Vercel Node.js"; + notes.runtime.notes.push( + `${runtimeName} runtime requires Hono backend. Hono will be selected.`, + ); + notes.backend.notes.push( + `${runtimeName} runtime requires Hono backend. It will be selected.`, + ); + notes.runtime.hasIssue = true; + notes.backend.hasIssue = true; + nextStack.backend = "hono"; + changed = true; + changes.push({ + category: "runtime", + message: `Backend set to 'Hono' (required by ${runtimeName})`, + }); + } + } + const isNuxt = nextStack.webFrontend.includes("nuxt"); const isSvelte = nextStack.webFrontend.includes("svelte"); const isSolid = nextStack.webFrontend.includes("solid");