feat(website-new): add Cloudflare Workers deployment via OpenNext (#20741)
## Summary - Adds `@opennextjs/cloudflare` adapter so `packages/twenty-website-new` can deploy to Cloudflare Workers - Two environments (`dev` / `prod`) wired via `wrangler.jsonc` env blocks - Existing Docker / EKS build path is untouched in this PR — the cutover happens in the paired infra PR Pairs with: https://github.com/twentyhq/twenty-infra/pull/__ (to be opened, will swap CI + decommission Helm/ArgoCD) ## Files added - `packages/twenty-website-new/wrangler.jsonc` — Worker config, `nodejs_compat` flag, R2 incremental cache, Cloudflare `IMAGES` binding, env-specific routes (`website-new.twenty-main.com` for dev; `twenty.com` + `www.twenty.com` for prod) - `packages/twenty-website-new/open-next.config.ts` — minimal config using `r2IncrementalCache` - `packages/twenty-website-new/.dev.vars.example` — local secrets template (`STRIPE_SECRET_KEY`, `ENTERPRISE_JWT_PRIVATE_KEY`) - `packages/twenty-website-new/public/_headers` — immutable cache headers for `/_next/static/*` ## Files modified - `packages/twenty-website-new/package.json` — adds `@opennextjs/cloudflare`, `wrangler` to devDeps; adds `preview`, `deploy:dev`, `deploy:prod`, `cf-typegen` scripts - `packages/twenty-website-new/next.config.ts` — calls `initOpenNextCloudflareForDev()` (no-op outside `next dev`); preserves Linaria CommonJS export - `packages/twenty-website-new/.gitignore` — ignores `.open-next/`, `.wrangler/`, `.dev.vars`, generated `cloudflare-env.d.ts` ## Compatibility notes - `enterprise-jwt.ts` uses Node `crypto` + `Buffer` — works on Workers with the `nodejs_compat` flag (compat date 2025-01-15, well past the 2024-09-23 minimum) - `sharp` stays as a build-time dep (Next/Image asset processing); runtime image optimization routes through the Cloudflare `IMAGES` binding - Linaria runs at build time, unaffected - Stripe SDK is HTTP-based, fine on Workers ## One-time CF setup required before this PR is useful The infra PR adds GitHub Actions wiring, but the Cloudflare account itself needs: - R2 buckets: `twenty-website-cache-dev`, `twenty-website-cache-prod` - Worker secrets per env (via `wrangler secret put --env <dev|prod>`): `STRIPE_SECRET_KEY`, `ENTERPRISE_JWT_PRIVATE_KEY` - An API token with `Workers Scripts:Edit`, `Workers R2 Storage:Edit`, `Zone DNS:Edit` on the `twenty.com` zone — stored as `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` in the infra repo's GitHub secrets - The Cloudflare Images subscription enabled on the account (binding is configured; \$5/mo + per-transformation pricing) ## Follow-up (out of scope) - Rename `packages/twenty-website-new` → `packages/twenty-website` and delete the legacy `packages/twenty-website` (mechanical, separate PR to keep this diff reviewable) - Remove `packages/twenty-docker/twenty-website-new/` once the EKS deploy is fully retired ## Test plan - [ ] `yarn install` resolves new devDeps cleanly - [ ] `cd packages/twenty-website-new && npx next build` still succeeds (Linaria path untouched) - [ ] `yarn preview` builds the Worker locally and serves on http://localhost:8788 - [ ] Smoke: `/`, `/pricing`, an enterprise-key-signing flow (needs `.dev.vars` populated) - [ ] After CF resources are provisioned: `yarn deploy:dev` succeeds and `website-new.twenty-main.com` serves the new Worker
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
NEXTJS_ENV=development
|
||||
|
||||
# Stripe — use a test-mode key locally
|
||||
STRIPE_SECRET_KEY=
|
||||
|
||||
# Enterprise JWT — generate a test RSA keypair for local dev
|
||||
ENTERPRISE_JWT_PRIVATE_KEY=
|
||||
@@ -35,6 +35,12 @@ yarn-error.log*
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# cloudflare / opennext
|
||||
.open-next/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
cloudflare-env.d.ts
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import path from 'path';
|
||||
import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
|
||||
import withLinaria, { type LinariaConfig } from 'next-with-linaria';
|
||||
import { APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
// Locale URL segments that are actually served (others are normalised away).
|
||||
// Mirrors LOCALE_BY_URL_SEGMENT keys in src/lib/i18n/utils/website-locale-segments.ts.
|
||||
const DEPLOYED_LOCALE_URL_SEGMENTS = ['en', 'fr'] as const;
|
||||
|
||||
// Raw locale codes (e.g. fr-FR, de-DE) that should redirect to the un-prefixed
|
||||
// path. Excludes pseudo-* locales and the deployed URL segments themselves.
|
||||
const RAW_LOCALE_PREFIXES_TO_STRIP = (
|
||||
Object.values(APP_LOCALES) as string[]
|
||||
).filter(
|
||||
(locale) =>
|
||||
!locale.startsWith('pseudo-') &&
|
||||
!(DEPLOYED_LOCALE_URL_SEGMENTS as readonly string[]).includes(locale),
|
||||
);
|
||||
|
||||
const SECURITY_HEADERS = [
|
||||
{
|
||||
@@ -66,8 +82,44 @@ const nextConfig: LinariaConfig = {
|
||||
},
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
return {
|
||||
beforeFiles: [
|
||||
// Root rewrites to the source locale.
|
||||
{ source: '/', destination: '/en' },
|
||||
// Any path that isn't already locale-prefixed (en/, fr/), an internal
|
||||
// Next.js path, a static asset folder, or a file with an extension
|
||||
// rewrites to the source locale prefix. Mirrors proxy.ts Rule 4.
|
||||
{
|
||||
source:
|
||||
'/:rest((?!en$|en/|fr$|fr/|api|_next/static|_next/image|favicon\\.ico|robots\\.txt|sitemap\\.xml|illustrations|lottie|fonts|.+\\..+).+)',
|
||||
destination: '/en/:rest',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
// Canonicalise www → apex. Host-based; fires before any locale logic.
|
||||
{
|
||||
source: '/:path*',
|
||||
has: [{ type: 'host', value: 'www.twenty.com' }],
|
||||
destination: 'https://twenty.com/:path*',
|
||||
permanent: true,
|
||||
},
|
||||
// Strip the source-locale prefix: /en/foo → /foo (301). Mirrors proxy.ts Rule 1.
|
||||
{ source: '/en', destination: '/', statusCode: 301 },
|
||||
{ source: '/en/:path*', destination: '/:path*', statusCode: 301 },
|
||||
// Normalise raw locale codes that aren't deployed URL segments
|
||||
// (e.g. /fr-FR/foo → /foo, /de-DE/foo → /foo). Mirrors proxy.ts Rule 3.
|
||||
...RAW_LOCALE_PREFIXES_TO_STRIP.flatMap((locale) => [
|
||||
{ source: `/${locale}`, destination: '/', permanent: true },
|
||||
{
|
||||
source: `/${locale}/:path*`,
|
||||
destination: '/:path*',
|
||||
permanent: true,
|
||||
},
|
||||
]),
|
||||
{
|
||||
source: '/user-guide',
|
||||
destination: 'https://docs.twenty.com/user-guide/introduction',
|
||||
@@ -177,4 +229,6 @@ const nextConfig: LinariaConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
initOpenNextCloudflareForDev();
|
||||
|
||||
module.exports = withLinaria(nextConfig);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
|
||||
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache';
|
||||
|
||||
export default defineCloudflareConfig({
|
||||
incrementalCache: r2IncrementalCache,
|
||||
});
|
||||
@@ -6,7 +6,11 @@
|
||||
"dev": "npx next dev",
|
||||
"build": "npx next build",
|
||||
"start": "npx next start",
|
||||
"convert-images": "node ./scripts/convert-png-to-webp.mjs"
|
||||
"convert-images": "node ./scripts/convert-png-to-webp.mjs",
|
||||
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
||||
"deploy:dev": "opennextjs-cloudflare build && opennextjs-cloudflare deploy --env dev",
|
||||
"deploy:prod": "opennextjs-cloudflare build && opennextjs-cloudflare deploy --env prod",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
@@ -39,6 +43,7 @@
|
||||
"@lingui/conf": "5.1.2",
|
||||
"@lingui/format-po": "5.1.2",
|
||||
"@lingui/swc-plugin": "^5.11.0",
|
||||
"@opennextjs/cloudflare": "^1.0.0",
|
||||
"@swc/core": "^1.15.11",
|
||||
"@swc/jest": "^0.2.39",
|
||||
"@types/jest": "^30.0.0",
|
||||
@@ -49,6 +54,7 @@
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-node": "^29.4.1",
|
||||
"ts-jest": "^29.1.1"
|
||||
"ts-jest": "^29.1.1",
|
||||
"wrangler": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/_next/static/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
@@ -1,137 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { proxy } from '@/proxy';
|
||||
|
||||
const SITE_ORIGIN = 'https://example.test';
|
||||
|
||||
type Cookies = Record<string, string>;
|
||||
type Headers = Record<string, string>;
|
||||
|
||||
const buildRequest = (
|
||||
pathname: string,
|
||||
{ cookies = {}, headers = {} }: { cookies?: Cookies; headers?: Headers } = {},
|
||||
): NextRequest => {
|
||||
const url = new URL(pathname, SITE_ORIGIN);
|
||||
const cookieHeader = Object.entries(cookies)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('; ');
|
||||
const allHeaders = new Headers(headers);
|
||||
if (cookieHeader.length > 0) {
|
||||
allHeaders.set('cookie', cookieHeader);
|
||||
}
|
||||
return new NextRequest(url, { headers: allHeaders });
|
||||
};
|
||||
|
||||
const getSetCookie = (response: Response): string | null =>
|
||||
response.headers.get('set-cookie');
|
||||
|
||||
describe('proxy: locale routing', () => {
|
||||
describe('canonicalisation of explicit prefixes', () => {
|
||||
it('301s /en/foo to /foo so the source locale never appears in URLs', () => {
|
||||
const response = proxy(buildRequest('/en/pricing'));
|
||||
|
||||
expect(response.status).toBe(301);
|
||||
expect(response.headers.get('location')).toBe(`${SITE_ORIGIN}/pricing`);
|
||||
});
|
||||
|
||||
it('301s the bare /en root to /', () => {
|
||||
const response = proxy(buildRequest('/en'));
|
||||
|
||||
expect(response.status).toBe(301);
|
||||
expect(response.headers.get('location')).toBe(`${SITE_ORIGIN}/`);
|
||||
});
|
||||
|
||||
it('preserves the query string on /en canonicalisation', () => {
|
||||
const response = proxy(buildRequest('/en/pricing?utm_source=newsletter'));
|
||||
|
||||
expect(response.status).toBe(301);
|
||||
expect(response.headers.get('location')).toBe(
|
||||
`${SITE_ORIGIN}/pricing?utm_source=newsletter`,
|
||||
);
|
||||
});
|
||||
|
||||
it('308s an unsupported but recognised locale prefix down to the canonical path', () => {
|
||||
const response = proxy(buildRequest('/de-DE/pricing'));
|
||||
|
||||
expect(response.status).toBe(308);
|
||||
expect(response.headers.get('location')).toBe(`${SITE_ORIGIN}/pricing`);
|
||||
});
|
||||
|
||||
it('308s the legacy fr-FR URL form to bare path (the public segment is /fr now)', () => {
|
||||
const response = proxy(buildRequest('/fr-FR/pricing'));
|
||||
|
||||
expect(response.status).toBe(308);
|
||||
expect(response.headers.get('location')).toBe(`${SITE_ORIGIN}/pricing`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonical published-locale segments render directly', () => {
|
||||
it('renders /fr/foo without any redirect or rewrite', () => {
|
||||
const response = proxy(buildRequest('/fr/pricing'));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
expect(response.headers.get('x-middleware-rewrite')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not write a NEXT_LOCALE cookie on locale-prefixed visits', () => {
|
||||
const response = proxy(buildRequest('/fr/pricing'));
|
||||
|
||||
expect(getSetCookie(response)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bare URLs always render the source locale', () => {
|
||||
it('rewrites /pricing to the internal /en/pricing for anonymous visitors', () => {
|
||||
const response = proxy(buildRequest('/pricing'));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('x-middleware-rewrite')).toBe(
|
||||
`${SITE_ORIGIN}/en/pricing`,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores Accept-Language entirely — French browsers still see English at /pricing', () => {
|
||||
const response = proxy(
|
||||
buildRequest('/pricing', {
|
||||
headers: { 'accept-language': 'fr-FR,fr;q=0.9' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('x-middleware-rewrite')).toBe(
|
||||
`${SITE_ORIGIN}/en/pricing`,
|
||||
);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores stored NEXT_LOCALE cookie — bare URLs are never cookie-redirected', () => {
|
||||
const response = proxy(
|
||||
buildRequest('/pricing', { cookies: { NEXT_LOCALE: 'fr-FR' } }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('x-middleware-rewrite')).toBe(
|
||||
`${SITE_ORIGIN}/en/pricing`,
|
||||
);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites the bare root / to the internal /en path', () => {
|
||||
const response = proxy(buildRequest('/'));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('x-middleware-rewrite')).toBe(
|
||||
`${SITE_ORIGIN}/en`,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves query strings when rewriting bare URLs', () => {
|
||||
const response = proxy(buildRequest('/pricing?utm_source=newsletter'));
|
||||
|
||||
expect(response.headers.get('x-middleware-rewrite')).toBe(
|
||||
`${SITE_ORIGIN}/en/pricing?utm_source=newsletter`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
import { KNOWN_PUBLIC_APP_LOCALE_BY_RAW } from '@/lib/i18n/utils/app-locale-set';
|
||||
import { LOCALE_BY_URL_SEGMENT } from '@/lib/i18n/utils/website-locale-segments';
|
||||
|
||||
export const proxy = (request: NextRequest) => {
|
||||
const { pathname, search } = request.nextUrl;
|
||||
|
||||
const firstSlash = pathname.indexOf('/', 1);
|
||||
const firstSegment =
|
||||
firstSlash === -1 ? pathname.slice(1) : pathname.slice(1, firstSlash);
|
||||
const tail = firstSlash === -1 ? '/' : pathname.slice(firstSlash);
|
||||
|
||||
const localeFromSegment = LOCALE_BY_URL_SEGMENT.get(firstSegment);
|
||||
|
||||
if (localeFromSegment === SOURCE_LOCALE) {
|
||||
const target = request.nextUrl.clone();
|
||||
target.pathname = tail;
|
||||
target.search = search;
|
||||
return NextResponse.redirect(target, 301);
|
||||
}
|
||||
|
||||
if (localeFromSegment !== undefined) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (KNOWN_PUBLIC_APP_LOCALE_BY_RAW.has(firstSegment)) {
|
||||
const target = request.nextUrl.clone();
|
||||
target.pathname = tail;
|
||||
target.search = search;
|
||||
return NextResponse.redirect(target, 308);
|
||||
}
|
||||
|
||||
const target = request.nextUrl.clone();
|
||||
target.pathname = `/${SOURCE_LOCALE}${pathname === '/' ? '' : pathname}`;
|
||||
target.search = search;
|
||||
return NextResponse.rewrite(target);
|
||||
};
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|illustrations|lottie|fonts|.*\\..*).*)',
|
||||
],
|
||||
};
|
||||
@@ -31,8 +31,8 @@ function buildNavItems(): MenuNavItemType[] {
|
||||
external: true,
|
||||
icon: 'book',
|
||||
preview: {
|
||||
image: '/images/product/feature/contacts.webp',
|
||||
imageAlt: 'Twenty companies list',
|
||||
image: '/images/shared/menu/user-guide-preview.webp',
|
||||
imageAlt: 'Twenty user guide preview',
|
||||
title: msg`Master every corner of Twenty`,
|
||||
description: msg`Step-by-step guides and playbooks to help your team get the most out of their workspace.`,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "twenty-website",
|
||||
"main": ".open-next/worker.js",
|
||||
"compatibility_date": "2025-01-15",
|
||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
||||
"assets": {
|
||||
"directory": ".open-next/assets",
|
||||
"binding": "ASSETS",
|
||||
},
|
||||
"observability": {
|
||||
"enabled": true,
|
||||
},
|
||||
"env": {
|
||||
"dev": {
|
||||
"name": "twenty-website-dev",
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "website-new.twenty-main.com",
|
||||
"custom_domain": true,
|
||||
},
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||
"bucket_name": "twenty-website-cache-dev",
|
||||
},
|
||||
],
|
||||
},
|
||||
"prod": {
|
||||
"name": "twenty-website-prod",
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "twenty.com",
|
||||
"custom_domain": true,
|
||||
},
|
||||
{
|
||||
"pattern": "www.twenty.com",
|
||||
"custom_domain": true,
|
||||
},
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||
"bucket_name": "twenty-website-cache-prod",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user