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