vinext boiler

This commit is contained in:
JonLuca De Caro
2026-03-18 16:08:28 -07:00
commit a62d314b4a
22 changed files with 4722 additions and 0 deletions

3
.env.example Normal file
View 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
View 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/

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
v24

9
.oxfmtrc.json Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

7
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,7 @@
onlyBuiltDependencies:
- "@prisma/engines"
- esbuild
- prisma
- sharp
- workerd
- wrangler

9
prisma.config.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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",
},
}