vinext boiler
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/vinext_boilerplate"
|
||||||
|
BASE_URL="http://localhost:3000"
|
||||||
|
NEXT_PUBLIC_BASE_URL="http://localhost:3000"
|
||||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next
|
||||||
|
/out
|
||||||
|
|
||||||
|
# build output
|
||||||
|
/dist
|
||||||
|
/.wrangler
|
||||||
|
|
||||||
|
# generated
|
||||||
|
/generated/prisma
|
||||||
|
cloudflare-env.d.ts
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.pnpm-debug.log*
|
||||||
|
.idea
|
||||||
|
next-env.d.ts
|
||||||
|
/dist/
|
||||||
9
.oxfmtrc.json
Normal file
9
.oxfmtrc.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"semi": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"sortPackageJson": false,
|
||||||
|
"ignorePatterns": [".idea", ".next", ".wrangler", "dist", "generated", "node_modules", "worker"]
|
||||||
|
}
|
||||||
27
.oxlintrc.json
Normal file
27
.oxlintrc.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"no-constant-binary-expression": "deny",
|
||||||
|
"no-constant-condition": "deny",
|
||||||
|
"no-unused-expressions": "deny",
|
||||||
|
"no-unused-vars": "deny",
|
||||||
|
"prefer-const": "deny",
|
||||||
|
"react/exhaustive-deps": "deny",
|
||||||
|
"react/rules-of-hooks": "deny",
|
||||||
|
"typescript/ban-ts-comment": "deny",
|
||||||
|
"typescript/consistent-type-imports": "deny",
|
||||||
|
"typescript/no-explicit-any": "deny",
|
||||||
|
"typescript/no-import-type-side-effects": "deny"
|
||||||
|
},
|
||||||
|
"ignorePatterns": [
|
||||||
|
".idea/**/*",
|
||||||
|
".next/**/*",
|
||||||
|
".wrangler/**/*",
|
||||||
|
"node_modules/**/*",
|
||||||
|
"generated/**/*",
|
||||||
|
"dist/**/*",
|
||||||
|
"worker/**/*",
|
||||||
|
"next-env.d.ts",
|
||||||
|
"cloudflare-env.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
81
README.md
Normal file
81
README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Vinext Boilerplate
|
||||||
|
|
||||||
|
Minimal Next.js web boilerplate with:
|
||||||
|
|
||||||
|
- Next.js 16 Pages Router
|
||||||
|
- React 19
|
||||||
|
- Prisma 7 + PostgreSQL
|
||||||
|
- `@t3-oss/env-nextjs` runtime env parsing
|
||||||
|
- `oxfmt` and `oxlint`
|
||||||
|
- `vinext` for local dev and Cloudflare-targeted builds
|
||||||
|
- `wrangler` for Cloudflare Workers deployment
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 24+
|
||||||
|
- `pnpm` 10+
|
||||||
|
- PostgreSQL access if you plan to run Prisma queries
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a local env file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Sync the sample Prisma schema when your database is ready:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common scripts
|
||||||
|
|
||||||
|
- `pnpm dev`: start the vinext development server
|
||||||
|
- `pnpm build`: create the production bundle with vinext
|
||||||
|
- `pnpm start`: run the built app locally
|
||||||
|
- `pnpm deploy`: build and deploy to Cloudflare Workers
|
||||||
|
- `pnpm deploy:dry-run`: build the deploy artifact without publishing it
|
||||||
|
- `pnpm vinext:check`: scan for vinext compatibility issues
|
||||||
|
- `pnpm cf-typegen`: generate `cloudflare-env.d.ts` from `wrangler.jsonc`
|
||||||
|
- `pnpm lint`: run Oxfmt checks and Oxlint
|
||||||
|
- `pnpm format:write`: format supported files with Oxfmt
|
||||||
|
- `pnpm typecheck`: run TypeScript checks
|
||||||
|
- `pnpm db:push`: push the Prisma schema to your database
|
||||||
|
- `pnpm db:studio`: open Prisma Studio
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
- `src/env.ts`: runtime env validation and defaults
|
||||||
|
- `src/server/db.ts`: Prisma singleton using the Postgres adapter
|
||||||
|
- `prisma/schema.prisma`: sample `Post` model and Prisma client generator
|
||||||
|
- `wrangler.jsonc`: Cloudflare Workers runtime configuration
|
||||||
|
|
||||||
|
## Cloudflare
|
||||||
|
|
||||||
|
Authenticate Wrangler before the first deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrangler login
|
||||||
|
```
|
||||||
|
|
||||||
|
Then deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
`wrangler.jsonc` intentionally does not include an `account_id`. Supply it through Wrangler login state or environment configuration when you wire the project to a real account.
|
||||||
15
next.config.ts
Normal file
15
next.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
import "./src/env";
|
||||||
|
|
||||||
|
const config: NextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
poweredByHeader: false,
|
||||||
|
generateEtags: true,
|
||||||
|
devIndicators: false,
|
||||||
|
reactCompiler: true,
|
||||||
|
compiler: {
|
||||||
|
removeConsole: process.env.NODE_ENV === "production" ? { exclude: ["error", "warn"] } : false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
57
package.json
Normal file
57
package.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "vinext-boilerplate",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vinext dev -H 0.0.0.0",
|
||||||
|
"build": "vinext build",
|
||||||
|
"start": "vinext start",
|
||||||
|
"deploy": "vinext deploy",
|
||||||
|
"deploy:dry-run": "vinext deploy --dry-run",
|
||||||
|
"vinext:check": "vinext check",
|
||||||
|
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
|
||||||
|
"lint": "pnpm format:check && pnpm lint:code",
|
||||||
|
"lint:code": "oxlint --quiet .",
|
||||||
|
"lint:code:fix": "oxlint --quiet --fix .",
|
||||||
|
"lint:fix": "pnpm format:write && pnpm lint:code:fix",
|
||||||
|
"format:check": "oxfmt --check .",
|
||||||
|
"format:write": "oxfmt .",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"postinstall": "prisma generate",
|
||||||
|
"dev:vinext": "vite dev --port 3001",
|
||||||
|
"build:vinext": "vite build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/adapter-pg": "7.5.0",
|
||||||
|
"@prisma/client": "7.5.0",
|
||||||
|
"@t3-oss/env-nextjs": "^0.13.10",
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"next": "^16.2.0",
|
||||||
|
"pg": "^8.20.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
|
"@types/node": "^25.5.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"oxfmt": "^0.41.0",
|
||||||
|
"oxlint": "^1.56.0",
|
||||||
|
"prisma": "^7.5.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vinext": "0.0.31",
|
||||||
|
"vite": "^8.0.0",
|
||||||
|
"wrangler": "^4.75.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.32.1",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24"
|
||||||
|
}
|
||||||
|
}
|
||||||
3771
pnpm-lock.yaml
generated
Normal file
3771
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
pnpm-workspace.yaml
Normal file
7
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- "@prisma/engines"
|
||||||
|
- esbuild
|
||||||
|
- prisma
|
||||||
|
- sharp
|
||||||
|
- workerd
|
||||||
|
- wrangler
|
||||||
9
prisma.config.ts
Normal file
9
prisma.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
datasource: {
|
||||||
|
url: process.env.DATABASE_URL,
|
||||||
|
},
|
||||||
|
});
|
||||||
25
prisma/schema.prisma
Normal file
25
prisma/schema.prisma
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../generated/prisma"
|
||||||
|
|
||||||
|
moduleFormat = "esm"
|
||||||
|
generatedFileExtension = "ts"
|
||||||
|
importFileExtension = "ts"
|
||||||
|
engineType = "client"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
model Post {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
slug String @unique
|
||||||
|
body String?
|
||||||
|
published Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([published, createdAt])
|
||||||
|
}
|
||||||
23
src/env.ts
Normal file
23
src/env.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { createEnv } from "@t3-oss/env-nextjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const defaultBaseUrl = "http://localhost:3000";
|
||||||
|
const defaultDatabaseUrl = "postgresql://postgres:postgres@127.0.0.1:5432/vinext_boilerplate";
|
||||||
|
|
||||||
|
export const env = createEnv({
|
||||||
|
server: {
|
||||||
|
DATABASE_URL: z.string().url().default(defaultDatabaseUrl),
|
||||||
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||||
|
BASE_URL: z.string().url().default(defaultBaseUrl),
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
NEXT_PUBLIC_BASE_URL: z.string().url().default(defaultBaseUrl),
|
||||||
|
},
|
||||||
|
runtimeEnv: {
|
||||||
|
DATABASE_URL: process.env.DATABASE_URL,
|
||||||
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
|
BASE_URL: process.env.BASE_URL,
|
||||||
|
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||||
|
},
|
||||||
|
emptyStringAsUndefined: true,
|
||||||
|
});
|
||||||
6
src/pages/_app.tsx
Normal file
6
src/pages/_app.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { AppProps } from "next/app";
|
||||||
|
import "~/styles/globals.css";
|
||||||
|
|
||||||
|
export default function App({ Component, pageProps }: AppProps) {
|
||||||
|
return <Component {...pageProps} />;
|
||||||
|
}
|
||||||
59
src/pages/index.tsx
Normal file
59
src/pages/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import Head from "next/head";
|
||||||
|
|
||||||
|
const setupSteps = [
|
||||||
|
"Copy .env.example to .env and point DATABASE_URL at a Postgres database.",
|
||||||
|
"Run pnpm db:push to sync the sample Post model.",
|
||||||
|
"Start the app with pnpm dev or build the Cloudflare-targeted bundle with pnpm build.",
|
||||||
|
];
|
||||||
|
|
||||||
|
const commands = ["pnpm dev", "pnpm build", "pnpm deploy", "pnpm db:push", "pnpm lint", "pnpm typecheck"];
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Vinext Boilerplate</title>
|
||||||
|
<meta content={"Minimal Next.js, Prisma, Oxc, Vinext, and Wrangler starter."} name={"description"} />
|
||||||
|
</Head>
|
||||||
|
<main className={"page-shell"}>
|
||||||
|
<section className={"hero-card"}>
|
||||||
|
<p className={"eyebrow"}>Next.js + Prisma + Cloudflare</p>
|
||||||
|
<h1>Vinext Boilerplate</h1>
|
||||||
|
<p className={"lead"}>
|
||||||
|
Minimal Pages Router starter built for local web development first and Cloudflare Workers deployment second.
|
||||||
|
</p>
|
||||||
|
<div className={"chip-row"}>
|
||||||
|
<span className={"chip"}>Next 16</span>
|
||||||
|
<span className={"chip"}>React 19</span>
|
||||||
|
<span className={"chip"}>Prisma 7</span>
|
||||||
|
<span className={"chip"}>vinext</span>
|
||||||
|
<span className={"chip"}>wrangler</span>
|
||||||
|
<span className={"chip"}>oxfmt + oxlint</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={"content-grid"}>
|
||||||
|
<article className={"panel"}>
|
||||||
|
<h2>Bootstrap flow</h2>
|
||||||
|
<ol className={"ordered-list"}>
|
||||||
|
{setupSteps.map((step) => (
|
||||||
|
<li key={step}>{step}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className={"panel"}>
|
||||||
|
<h2>Key commands</h2>
|
||||||
|
<ul className={"command-list"}>
|
||||||
|
{commands.map((command) => (
|
||||||
|
<li key={command}>
|
||||||
|
<code>{command}</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/server/db.ts
Normal file
23
src/server/db.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { PrismaClient } from "@/db";
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
|
const adapter = new PrismaPg({
|
||||||
|
connectionString: env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPrismaClient = () =>
|
||||||
|
new PrismaClient({
|
||||||
|
adapter,
|
||||||
|
log: env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: ReturnType<typeof createPrismaClient> | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const db = globalForPrisma.prisma ?? createPrismaClient();
|
||||||
|
|
||||||
|
if (env.NODE_ENV !== "production") {
|
||||||
|
globalForPrisma.prisma = db;
|
||||||
|
}
|
||||||
9
src/server/posts.ts
Normal file
9
src/server/posts.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { db } from "~/server/db";
|
||||||
|
|
||||||
|
export const listRecentPosts = async (limit: number = 5) =>
|
||||||
|
db.post.findMany({
|
||||||
|
take: limit,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
161
src/styles/globals.css
Normal file
161
src/styles/globals.css
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--background: #f6f1e8;
|
||||||
|
--background-accent: #efe5d6;
|
||||||
|
--surface: rgba(255, 255, 255, 0.72);
|
||||||
|
--surface-strong: rgba(255, 255, 255, 0.88);
|
||||||
|
--border: rgba(35, 43, 61, 0.12);
|
||||||
|
--text: #1d2433;
|
||||||
|
--text-muted: #5d6578;
|
||||||
|
--accent: #0e6ba8;
|
||||||
|
--accent-strong: #084d78;
|
||||||
|
--shadow: 0 18px 50px rgba(29, 36, 51, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#__next {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(14, 107, 168, 0.12), transparent 32%),
|
||||||
|
radial-gradient(circle at right 18%, rgba(224, 148, 76, 0.15), transparent 28%),
|
||||||
|
linear-gradient(180deg, var(--background), var(--background-accent));
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Palatino, Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: "SF Mono", "Monaco", "Cascadia Code", "Roboto Mono", Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 1100px;
|
||||||
|
padding: 40px 20px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card,
|
||||||
|
.panel {
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 28px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-family: "SF Mono", "Monaco", "Cascadia Code", "Roboto Mono", Consolas, monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card h1 {
|
||||||
|
font-size: clamp(2.4rem, 5vw, 4.8rem);
|
||||||
|
line-height: 0.95;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 10ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 20px 0 0;
|
||||||
|
max-width: 48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
background: var(--surface-strong);
|
||||||
|
border: 1px solid rgba(14, 107, 168, 0.14);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
display: inline-flex;
|
||||||
|
font-family: "SF Mono", "Monaco", "Cascadia Code", "Roboto Mono", Consolas, monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 9px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ordered-list,
|
||||||
|
.command-list {
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-list {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-list code {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border: 1px solid rgba(29, 36, 51, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
display: block;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.page-shell {
|
||||||
|
padding: 20px 14px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card,
|
||||||
|
.panel {
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card,
|
||||||
|
.panel {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
tsconfig.json
Normal file
40
tsconfig.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": false,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["./src/*"],
|
||||||
|
"@/db": ["./generated/prisma/client"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"cloudflare-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
"**/*.cjs",
|
||||||
|
"**/*.js",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules", "generated", "dist"]
|
||||||
|
}
|
||||||
20
vite.config.ts
Normal file
20
vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { cloudflare } from "@cloudflare/vite-plugin";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vinext from "vinext";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vinext(), cloudflare()],
|
||||||
|
resolve: {
|
||||||
|
tsconfigPaths: true,
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: [
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react-dom/client",
|
||||||
|
"react-dom/server.edge",
|
||||||
|
"react/jsx-dev-runtime",
|
||||||
|
"react/jsx-runtime",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
338
worker/index.ts
Normal file
338
worker/index.ts
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* Cloudflare Worker entry point -- auto-generated by vinext deploy.
|
||||||
|
* Edit freely or delete to regenerate on next deploy.
|
||||||
|
*/
|
||||||
|
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
|
||||||
|
import type { ImageConfig } from "vinext/server/image-optimization";
|
||||||
|
import {
|
||||||
|
matchRedirect,
|
||||||
|
matchRewrite,
|
||||||
|
matchHeaders,
|
||||||
|
requestContextFromRequest,
|
||||||
|
applyMiddlewareRequestHeaders,
|
||||||
|
isExternalUrl,
|
||||||
|
proxyExternalRequest,
|
||||||
|
sanitizeDestination,
|
||||||
|
} from "vinext/config/config-matchers";
|
||||||
|
|
||||||
|
// @ts-expect-error -- virtual module resolved by vinext at build time
|
||||||
|
import { renderPage, handleApiRoute, runMiddleware, vinextConfig } from "virtual:vinext-server-entry";
|
||||||
|
|
||||||
|
interface Env {
|
||||||
|
ASSETS: Fetcher;
|
||||||
|
IMAGES: {
|
||||||
|
input(stream: ReadableStream): {
|
||||||
|
transform(options: Record<string, unknown>): {
|
||||||
|
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecutionContext {
|
||||||
|
waitUntil(promise: Promise<unknown>): void;
|
||||||
|
passThroughOnException(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract config values (embedded at build time in the server entry)
|
||||||
|
const basePath: string = vinextConfig?.basePath ?? "";
|
||||||
|
const trailingSlash: boolean = vinextConfig?.trailingSlash ?? false;
|
||||||
|
const configRedirects = vinextConfig?.redirects ?? [];
|
||||||
|
const configRewrites = vinextConfig?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] };
|
||||||
|
const configHeaders = vinextConfig?.headers ?? [];
|
||||||
|
const imageConfig: ImageConfig | undefined = vinextConfig?.images ? {
|
||||||
|
dangerouslyAllowSVG: vinextConfig.images.dangerouslyAllowSVG,
|
||||||
|
contentDispositionType: vinextConfig.images.contentDispositionType,
|
||||||
|
contentSecurityPolicy: vinextConfig.images.contentSecurityPolicy,
|
||||||
|
} : undefined;
|
||||||
|
|
||||||
|
function hasBasePath(pathname: string, basePath: string): boolean {
|
||||||
|
if (!basePath) return false;
|
||||||
|
return pathname === basePath || pathname.startsWith(basePath + "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripBasePath(pathname: string, basePath: string): string {
|
||||||
|
if (!hasBasePath(pathname, basePath)) return pathname;
|
||||||
|
return pathname.slice(basePath.length) || "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
let pathname = url.pathname;
|
||||||
|
let urlWithQuery = pathname + url.search;
|
||||||
|
|
||||||
|
// Block protocol-relative URL open redirects (//evil.com/ or /\evil.com/).
|
||||||
|
// Normalize backslashes: browsers treat /\ as // in URL context.
|
||||||
|
const safePath = pathname.replaceAll("\\", "/");
|
||||||
|
if (safePath.startsWith("//")) {
|
||||||
|
return new Response("404 Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Strip basePath ─────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const stripped = stripBasePath(pathname, basePath);
|
||||||
|
if (stripped !== pathname) {
|
||||||
|
urlWithQuery = stripped + url.search;
|
||||||
|
pathname = stripped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Image optimization via Cloudflare Images binding ──────────
|
||||||
|
// Checked after basePath stripping so /<basePath>/_vinext/image works.
|
||||||
|
if (pathname === "/_vinext/image") {
|
||||||
|
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
|
||||||
|
return handleImageOptimization(request, {
|
||||||
|
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
|
||||||
|
transformImage: async (body, { width, format, quality }) => {
|
||||||
|
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
|
||||||
|
return result.response();
|
||||||
|
},
|
||||||
|
}, allowedWidths, imageConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Trailing slash normalization ────────────────────────────
|
||||||
|
if (pathname !== "/" && pathname !== "/api" && !pathname.startsWith("/api/")) {
|
||||||
|
const hasTrailing = pathname.endsWith("/");
|
||||||
|
if (trailingSlash && !hasTrailing) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 308,
|
||||||
|
headers: { Location: basePath + pathname + "/" + url.search },
|
||||||
|
});
|
||||||
|
} else if (!trailingSlash && hasTrailing) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 308,
|
||||||
|
headers: { Location: basePath + pathname.replace(/\/+$/, "") + url.search },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a request with the basePath-stripped URL for middleware and
|
||||||
|
// downstream handlers. In Workers the incoming request URL still
|
||||||
|
// contains basePath; prod-server constructs its webRequest from
|
||||||
|
// the already-stripped URL, so we replicate that here.
|
||||||
|
if (basePath) {
|
||||||
|
const strippedUrl = new URL(request.url);
|
||||||
|
strippedUrl.pathname = pathname;
|
||||||
|
request = new Request(strippedUrl, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build request context for pre-middleware config matching. Redirects
|
||||||
|
// run before middleware in Next.js. Header match conditions also use the
|
||||||
|
// original request snapshot even though header merging happens later so
|
||||||
|
// middleware response headers can still take precedence.
|
||||||
|
// beforeFiles, afterFiles, and fallback rewrites run after middleware
|
||||||
|
// (App Router order), so they use postMwReqCtx created after
|
||||||
|
// x-middleware-request-* headers are unpacked into request.
|
||||||
|
const reqCtx = requestContextFromRequest(request);
|
||||||
|
|
||||||
|
// ── 3. Apply redirects from next.config.js ────────────────────
|
||||||
|
if (configRedirects.length) {
|
||||||
|
const redirect = matchRedirect(pathname, configRedirects, reqCtx);
|
||||||
|
if (redirect) {
|
||||||
|
const dest = sanitizeDestination(
|
||||||
|
basePath &&
|
||||||
|
!isExternalUrl(redirect.destination) &&
|
||||||
|
!hasBasePath(redirect.destination, basePath)
|
||||||
|
? basePath + redirect.destination
|
||||||
|
: redirect.destination,
|
||||||
|
);
|
||||||
|
return new Response(null, {
|
||||||
|
status: redirect.permanent ? 308 : 307,
|
||||||
|
headers: { Location: dest },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Run middleware ──────────────────────────────────────────
|
||||||
|
let resolvedUrl = urlWithQuery;
|
||||||
|
const middlewareHeaders: Record<string, string | string[]> = {};
|
||||||
|
let middlewareRewriteStatus: number | undefined;
|
||||||
|
if (typeof runMiddleware === "function") {
|
||||||
|
const result = await runMiddleware(request, ctx);
|
||||||
|
|
||||||
|
if (!result.continue) {
|
||||||
|
if (result.redirectUrl) {
|
||||||
|
const redirectHeaders = new Headers({ Location: result.redirectUrl });
|
||||||
|
if (result.responseHeaders) {
|
||||||
|
for (const [key, value] of result.responseHeaders) {
|
||||||
|
redirectHeaders.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Response(null, {
|
||||||
|
status: result.redirectStatus ?? 307,
|
||||||
|
headers: redirectHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (result.response) {
|
||||||
|
return result.response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect middleware response headers to merge into final response.
|
||||||
|
// Use an array for Set-Cookie to preserve multiple values.
|
||||||
|
if (result.responseHeaders) {
|
||||||
|
for (const [key, value] of result.responseHeaders) {
|
||||||
|
if (key === "set-cookie") {
|
||||||
|
const existing = middlewareHeaders[key];
|
||||||
|
if (Array.isArray(existing)) {
|
||||||
|
existing.push(value);
|
||||||
|
} else if (existing) {
|
||||||
|
middlewareHeaders[key] = [existing as string, value];
|
||||||
|
} else {
|
||||||
|
middlewareHeaders[key] = [value];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
middlewareHeaders[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply middleware rewrite
|
||||||
|
if (result.rewriteUrl) {
|
||||||
|
resolvedUrl = result.rewriteUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply custom status code from middleware rewrite
|
||||||
|
middlewareRewriteStatus = result.rewriteStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unpack x-middleware-request-* headers into the actual request and strip
|
||||||
|
// all x-middleware-* internal signals. Rebuilds postMwReqCtx for use by
|
||||||
|
// beforeFiles, afterFiles, and fallback config rules (which run after
|
||||||
|
// middleware per the Next.js execution order).
|
||||||
|
const { postMwReqCtx, request: postMwReq } = applyMiddlewareRequestHeaders(middlewareHeaders, request);
|
||||||
|
request = postMwReq;
|
||||||
|
|
||||||
|
// Config header matching must keep using the original normalized pathname
|
||||||
|
// even if middleware rewrites the downstream route/render target.
|
||||||
|
let resolvedPathname = resolvedUrl.split("?")[0];
|
||||||
|
|
||||||
|
// ── 5. Apply custom headers from next.config.js ───────────────
|
||||||
|
// Config headers are additive for multi-value headers (Vary,
|
||||||
|
// Set-Cookie) and override for everything else. Vary values are
|
||||||
|
// comma-joined per HTTP spec. Set-Cookie values are accumulated
|
||||||
|
// as arrays (RFC 6265 forbids comma-joining cookies).
|
||||||
|
// Middleware headers take precedence: skip config keys already set
|
||||||
|
// by middleware so middleware always wins for the same key.
|
||||||
|
if (configHeaders.length) {
|
||||||
|
const matched = matchHeaders(pathname, configHeaders, reqCtx);
|
||||||
|
for (const h of matched) {
|
||||||
|
const lk = h.key.toLowerCase();
|
||||||
|
if (lk === "set-cookie") {
|
||||||
|
const existing = middlewareHeaders[lk];
|
||||||
|
if (Array.isArray(existing)) {
|
||||||
|
existing.push(h.value);
|
||||||
|
} else if (existing) {
|
||||||
|
middlewareHeaders[lk] = [existing as string, h.value];
|
||||||
|
} else {
|
||||||
|
middlewareHeaders[lk] = [h.value];
|
||||||
|
}
|
||||||
|
} else if (lk === "vary" && middlewareHeaders[lk]) {
|
||||||
|
middlewareHeaders[lk] += ", " + h.value;
|
||||||
|
} else if (!(lk in middlewareHeaders)) {
|
||||||
|
// Middleware headers take precedence: only set if middleware
|
||||||
|
// did not already place this key on the response.
|
||||||
|
middlewareHeaders[lk] = h.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// <20><>─ 6. Apply beforeFiles rewrites from next.config.js ─────────
|
||||||
|
if (configRewrites.beforeFiles?.length) {
|
||||||
|
const rewritten = matchRewrite(resolvedPathname, configRewrites.beforeFiles, postMwReqCtx);
|
||||||
|
if (rewritten) {
|
||||||
|
if (isExternalUrl(rewritten)) {
|
||||||
|
return proxyExternalRequest(request, rewritten);
|
||||||
|
}
|
||||||
|
resolvedUrl = rewritten;
|
||||||
|
resolvedPathname = rewritten.split("?")[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7. API routes ─────────────────────────────────────────────
|
||||||
|
if (resolvedPathname.startsWith("/api/") || resolvedPathname === "/api") {
|
||||||
|
const response = typeof handleApiRoute === "function"
|
||||||
|
? await handleApiRoute(request, resolvedUrl)
|
||||||
|
: new Response("404 - API route not found", { status: 404 });
|
||||||
|
return mergeHeaders(response, middlewareHeaders, middlewareRewriteStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 8. Apply afterFiles rewrites from next.config.js ──────────
|
||||||
|
if (configRewrites.afterFiles?.length) {
|
||||||
|
const rewritten = matchRewrite(resolvedPathname, configRewrites.afterFiles, postMwReqCtx);
|
||||||
|
if (rewritten) {
|
||||||
|
if (isExternalUrl(rewritten)) {
|
||||||
|
return proxyExternalRequest(request, rewritten);
|
||||||
|
}
|
||||||
|
resolvedUrl = rewritten;
|
||||||
|
resolvedPathname = rewritten.split("?")[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 9. Page routes ────────────────────────────────────────────
|
||||||
|
let response: Response | undefined;
|
||||||
|
if (typeof renderPage === "function") {
|
||||||
|
response = await renderPage(request, resolvedUrl, null, ctx);
|
||||||
|
|
||||||
|
// ── 10. Fallback rewrites (if SSR returned 404) ─────────────
|
||||||
|
if (response && response.status === 404 && configRewrites.fallback?.length) {
|
||||||
|
const fallbackRewrite = matchRewrite(resolvedPathname, configRewrites.fallback, postMwReqCtx);
|
||||||
|
if (fallbackRewrite) {
|
||||||
|
if (isExternalUrl(fallbackRewrite)) {
|
||||||
|
return proxyExternalRequest(request, fallbackRewrite);
|
||||||
|
}
|
||||||
|
response = await renderPage(request, fallbackRewrite, null, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
return new Response("404 - Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeHeaders(response, middlewareHeaders, middlewareRewriteStatus);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[vinext] Worker error:", error);
|
||||||
|
return new Response("Internal Server Error", { status: 500 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge middleware/config headers into a response.
|
||||||
|
* Response headers take precedence over middleware headers for all headers
|
||||||
|
* except Set-Cookie, which is additive (both middleware and response cookies
|
||||||
|
* are preserved). Matches the behavior in prod-server.ts. Uses getSetCookie()
|
||||||
|
* to preserve multiple Set-Cookie values.
|
||||||
|
*/
|
||||||
|
function mergeHeaders(
|
||||||
|
response: Response,
|
||||||
|
extraHeaders: Record<string, string | string[]>,
|
||||||
|
statusOverride?: number,
|
||||||
|
): Response {
|
||||||
|
if (!Object.keys(extraHeaders).length && !statusOverride) return response;
|
||||||
|
const merged = new Headers();
|
||||||
|
// Middleware/config headers go in first (lower precedence)
|
||||||
|
for (const [k, v] of Object.entries(extraHeaders)) {
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
for (const item of v) merged.append(k, item);
|
||||||
|
} else {
|
||||||
|
merged.set(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Response headers overlay them (higher precedence), except Set-Cookie
|
||||||
|
// which is additive (both middleware and response cookies should be sent).
|
||||||
|
response.headers.forEach((v, k) => {
|
||||||
|
if (k === "set-cookie") return;
|
||||||
|
merged.set(k, v);
|
||||||
|
});
|
||||||
|
const responseCookies = response.headers.getSetCookie?.() ?? [];
|
||||||
|
for (const cookie of responseCookies) merged.append("set-cookie", cookie);
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: statusOverride ?? response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: merged,
|
||||||
|
});
|
||||||
|
}
|
||||||
12
wrangler.jsonc
Normal file
12
wrangler.jsonc
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
|
"name": "vinext-boilerplate",
|
||||||
|
"compatibility_date": "2026-03-17",
|
||||||
|
"compatibility_flags": ["nodejs_compat"],
|
||||||
|
"main": "./worker/index.ts",
|
||||||
|
"assets": {
|
||||||
|
"directory": "dist/client",
|
||||||
|
"not_found_handling": "none",
|
||||||
|
"binding": "ASSETS",
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user