diff --git a/.env.example b/.env.example index 8009ecc..156fd00 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,2 @@ APP_BASE_URL=http://localhost:3000 +ALLOW_SEARCH_ENGINE_INDEXING=false diff --git a/Dockerfile b/Dockerfile index 02fa786..80cd801 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ COPY --chown=node:node . . RUN apt update && apt -yqq install tini jq RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install \ - --ignore-scripts && pnpm add sharp && pnpm build + --ignore-scripts && pnpm add sharp && pnpm build --verbose # ----------------------------------------------------------------------------- # Compile the application and install production only dependencies. @@ -38,12 +38,10 @@ FROM base AS pruner # Required generated files COPY --from=builder /srv/.next/standalone /srv/.next/standalone COPY --from=builder /srv/.next/static /srv/.next/standalone/.next/static -COPY --from=builder /srv/public /srv/.next/standalone/public # Required metadata files COPY --from=builder /srv/package.json /srv/package.json COPY --from=builder /srv/pnpm-lock.yaml /srv/pnpm-lock.yaml -COPY --from=builder /srv/next.config.mjs /srv/next.config.mjs COPY --from=builder /srv/.npmrc /srv/.npmrc # Install production dependencies and cleanup node_modules. @@ -68,8 +66,10 @@ ENV APP_BASE_URL=$APP_BASE_URL # Copy the build output files from the pruner stage. # Automatically leverage output traces to reduce image size # @ref: https://nextjs.org/docs/app/api-reference/next-config-js/output -COPY --from=pruner --chown=nonroot:nonroot /srv/.next/standalone /srv -COPY --from=pruner --chown=nonroot:nonroot /srv/next.config.mjs /srv/next.config.mjs +COPY --from=pruner /srv/.next/standalone /srv +COPY --from=builder /srv/public /srv/public +COPY --from=builder /srv/next.config.mjs /srv/next.config.mjs +COPY --from=builder /srv/scripts/cache-handler.mjs /srv/scripts/cache-handler.mjs # Copy some utilities from builder image. COPY --from=builder /usr/bin/tini /usr/bin/tini diff --git a/next.config.mjs b/next.config.mjs index 6743e33..1e731bd 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,10 +1,13 @@ import NextBundleAnalyzer from '@next/bundle-analyzer' +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'node:path' -// Avoid build and lint error in Docker or Vercel deployment -const isProduction = - process.env.NODE_ENV === 'production' || - process.env.IS_VERCEL_ENV === 'true' || - process.env.FLY_MACHINE_ID +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +const isFly = process.env.FLY_MACHINE_ID +const isVercel = process.env.IS_VERCEL_ENV === 'true' +const isProduction = process.env.NODE_ENV === 'production' || isVercel || isFly const withBundleAnalyzer = NextBundleAnalyzer({ enabled: process.env.ANALYZE === 'true', @@ -14,16 +17,29 @@ const withBundleAnalyzer = NextBundleAnalyzer({ /** @type {import('next').NextConfig} */ const nextConfig = { output: 'standalone', + cleanDistDir: true, reactStrictMode: true, poweredByHeader: false, - cleanDistDir: true, + productionBrowserSourceMaps: false, images: { remotePatterns: [{ protocol: 'https', hostname: '**' }] }, + // @ref: https://nextjs.org/blog/next-14-1#improved-self-hosting - // cacheHandler: require.resolve('./cache-handler.js'), - // cacheMaxMemorySize: 0, // disable default in-memory caching + cacheMaxMemorySize: 0, // disable default in-memory caching + cacheHandler: + process.env.NODE_ENV === 'production' && !isVercel + ? resolve(__dirname, 'scripts/cache-handler.mjs') + : undefined, + eslint: { ignoreDuringBuilds: isProduction }, typescript: { ignoreBuildErrors: isProduction }, logging: { fetches: { fullUrl: true } }, + + experimental: { + // This is required for the experimental feature of + // pre-populating the cache with the initial data. + instrumentationHook: true, + }, + rewrites() { return [{ source: '/healthz', destination: '/api/healthz' }] }, diff --git a/package.json b/package.json index 0262029..6333c0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "next-start", - "version": "14.0.0", + "version": "14.1.0", "license": "MIT", "private": true, "scripts": { @@ -24,6 +24,7 @@ "pre-dev": "docker compose up -d --remove-orphans" }, "dependencies": { + "@neshca/cache-handler": "^1.7.3", "@next/bundle-analyzer": "^14.2.13", "@t3-oss/env-core": "^0.11.1", "lucide-react": "^0.446.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39bb6e3..ce9945a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@neshca/cache-handler': + specifier: ^1.7.3 + version: 1.7.3(next@14.2.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(redis@4.7.0) '@next/bundle-analyzer': specifier: ^14.2.13 version: 14.2.13 @@ -154,6 +157,12 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@neshca/cache-handler@1.7.3': + resolution: {integrity: sha512-jRLTO7pb/JRWvgGO8k+fB/tGhfTiY4N79N7auBi/p9E3G5JU8pUQWcdFfJirrODfxWwYghTPklwWx0J5WeR2sQ==} + peerDependencies: + next: '>=13.5.1' + redis: '>=4.6' + '@next/bundle-analyzer@14.2.13': resolution: {integrity: sha512-CQOVKmfenD9HsG4AmyXG2ElMvtGKAT9TlS2JLgpL/EORi4WX+QMiQ8Ri6b+A7HRT+AiUGjsYnocIOET59i6Jfw==} @@ -240,6 +249,35 @@ packages: '@polka/url@1.0.0-next.28': resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} + '@redis/bloom@1.2.0': + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/client@1.6.0': + resolution: {integrity: sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==} + engines: {node: '>=14'} + + '@redis/graph@1.1.1': + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/json@1.0.7': + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/search@1.2.0': + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/time-series@1.1.0': + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -510,6 +548,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -840,6 +882,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + get-intrinsic@1.2.4: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} @@ -1483,6 +1529,9 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + redis@4.7.0: + resolution: {integrity: sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==} + reflect.getprototypeof@1.0.6: resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==} engines: {node: '>= 0.4'} @@ -1798,6 +1847,9 @@ packages: utf-8-validate: optional: true + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.5.1: resolution: {integrity: sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==} engines: {node: '>= 14'} @@ -1877,6 +1929,13 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@neshca/cache-handler@1.7.3(next@14.2.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(redis@4.7.0)': + dependencies: + cluster-key-slot: 1.1.2 + lru-cache: 10.4.3 + next: 14.2.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + redis: 4.7.0 + '@next/bundle-analyzer@14.2.13': dependencies: webpack-bundle-analyzer: 4.10.1 @@ -1936,6 +1995,32 @@ snapshots: '@polka/url@1.0.0-next.28': {} + '@redis/bloom@1.2.0(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + + '@redis/client@1.6.0': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + + '@redis/graph@1.1.1(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + + '@redis/json@1.0.7(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + + '@redis/search@1.2.0(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + + '@redis/time-series@1.1.0(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.10.4': {} @@ -2260,6 +2345,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2752,6 +2839,8 @@ snapshots: functions-have-names@1.2.3: {} + generic-pool@3.9.0: {} + get-intrinsic@1.2.4: dependencies: es-errors: 1.3.0 @@ -3319,6 +3408,15 @@ snapshots: dependencies: picomatch: 2.3.1 + redis@4.7.0: + dependencies: + '@redis/bloom': 1.2.0(@redis/client@1.6.0) + '@redis/client': 1.6.0 + '@redis/graph': 1.1.1(@redis/client@1.6.0) + '@redis/json': 1.0.7(@redis/client@1.6.0) + '@redis/search': 1.2.0(@redis/client@1.6.0) + '@redis/time-series': 1.1.0(@redis/client@1.6.0) + reflect.getprototypeof@1.0.6: dependencies: call-bind: 1.0.7 @@ -3723,6 +3821,8 @@ snapshots: ws@7.5.10: {} + yallist@4.0.0: {} + yaml@2.5.1: {} yocto-queue@0.1.0: {} diff --git a/public/site.webmanifest b/public/site.webmanifest deleted file mode 100644 index 517a391..0000000 --- a/public/site.webmanifest +++ /dev/null @@ -1,51 +0,0 @@ -{ - "lang": "en", - "dir": "ltr", - "name": "Next Start", - "short_name": "next-start", - "description": "A starter for Next.js with Tailwind CSS and Typescript", - "theme_color": "#121314", - "background_color": "#ffffff", - "start_url": "/?source=pwa", - "id": "/?source=pwa", - "icons": [ - { - "src": "/favicon.svg", - "sizes": "36x36", - "type": "image/svg+xml", - "density": "0.75" - }, - { - "src": "/favicon.svg", - "sizes": "48x48", - "type": "image/svg+xml", - "density": "1.0" - }, - { - "src": "/favicon.svg", - "sizes": "72x72", - "type": "image/svg+xml", - "density": "1.5" - }, - { - "src": "/favicon.svg", - "sizes": "96x96", - "type": "image/svg+xml", - "density": "2.0" - }, - { - "src": "/favicon.svg", - "sizes": "144x144", - "type": "image/svg+xml", - "density": "3.0" - }, - { - "src": "/favicon.svg", - "sizes": "192x192", - "type": "image/svg+xml", - "density": "4.0" - } - ], - "display": "standalone", - "orientation": "natural" -} diff --git a/scripts/cache-handler.mjs b/scripts/cache-handler.mjs new file mode 100644 index 0000000..689b747 --- /dev/null +++ b/scripts/cache-handler.mjs @@ -0,0 +1,13 @@ +import { CacheHandler } from '@neshca/cache-handler' +import createLocalHandler from '@neshca/cache-handler/local-lru' + +CacheHandler.onCreation(async () => { + const localHandler = createLocalHandler({ + maxItemsNumber: 10000, + maxItemSizeBytes: 1024 * 1024 * 500, + }) + + return { handlers: [localHandler] } +}) + +export default CacheHandler diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 33ec255..d21a2a4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -15,12 +15,14 @@ export const metadata: Metadata = { applicationName: 'Next Start', description: 'A starter project for Next.js with Tailwind CSS and Typescript.', keywords: ['nextjs', 'react', 'starter', 'boilerplate'], - robots: { index: true, follow: true }, - manifest: '/site.webmanifest', + robots: { + index: ENV.ALLOW_SEARCH_ENGINE_INDEXING, + follow: ENV.ALLOW_SEARCH_ENGINE_INDEXING, + }, + manifest: '/manifest.webmanifest', icons: [ { rel: 'icon', type: 'image/x-icon', url: '/favicon.ico' }, { rel: 'icon', type: 'image/svg+xml', url: '/favicon.svg' }, - { rel: 'icon', type: 'image/png', url: '/favicon.png' }, { rel: 'apple-touch-icon', url: '/favicon.png' }, ], metadataBase: new URL(ENV.APP_BASE_URL), diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..269fe5f --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,56 @@ +import ENV from '#/env' + +import type { MetadataRoute } from 'next' + +export const dynamicParams = false // Prevents dynamic route parameters. +export const dynamic = 'force-static' // Forces the page to be statically rendered. +export const fetchCache = 'force-cache' // Force caching of fetch requests. +export const revalidate = 0 // Disable Incremental Static Regeneration (ISR). + +export default function manifest(): MetadataRoute.Manifest { + return { + lang: 'en', + dir: 'ltr', + name: 'Next Start', + short_name: 'next-start', + description: 'A starter for Next.js with Tailwind CSS and Typescript', + theme_color: '#121314', + background_color: '#ffffff', + start_url: `${ENV.APP_BASE_URL}/?source=pwa`, + id: `${ENV.APP_BASE_URL}/?source=pwa`, + icons: [ + { + src: '/favicon.svg', + sizes: '36x36', + type: 'image/svg+xml', + }, + { + src: '/favicon.svg', + sizes: '48x48', + type: 'image/svg+xml', + }, + { + src: '/favicon.svg', + sizes: '72x72', + type: 'image/svg+xml', + }, + { + src: '/favicon.svg', + sizes: '96x96', + type: 'image/svg+xml', + }, + { + src: '/favicon.svg', + sizes: '144x144', + type: 'image/svg+xml', + }, + { + src: '/favicon.svg', + sizes: '192x192', + type: 'image/svg+xml', + }, + ], + display: 'standalone', + orientation: 'natural', + } +} diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..92f828c --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,20 @@ +import ENV from '#/env' + +import type { MetadataRoute } from 'next' + +export const dynamicParams = false // Prevents dynamic route parameters. +export const dynamic = 'force-static' // Forces the page to be statically rendered. +export const fetchCache = 'force-cache' // Force caching of fetch requests. +export const revalidate = 0 // Disable Incremental Static Regeneration (ISR). + +export default function robots(): MetadataRoute.Robots { + const DISALLOW_LIST: string[] = ['/api', '/_next'] + + return { + rules: { + userAgent: '*', + disallow: ENV.ALLOW_SEARCH_ENGINE_INDEXING ? DISALLOW_LIST : '*', + }, + sitemap: ENV.ALLOW_SEARCH_ENGINE_INDEXING ? `${ENV.APP_BASE_URL}/sitemap.xml` : undefined, + } +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index e816f0f..158ed4f 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -3,8 +3,10 @@ import ENV from '#/env' import { MetadataRoute } from 'next/types' // @reference: https://github.com/vercel/next.js/issues/49373 -export const dynamic = 'force-dynamic' -export const revalidate = 0 +export const dynamicParams = false // Prevents dynamic route parameters. +export const dynamic = 'force-dynamic' // Forces the page to be dynamically rendered on each request. +export const fetchCache = 'force-cache' // Force caching of fetch requests. +export const revalidate = 0 // Disable Incremental Static Regeneration (ISR). export default async function sitemap(): Promise { const baseUrl = ENV.APP_BASE_URL diff --git a/src/env.ts b/src/env.ts index 7a00a59..2ac132a 100644 --- a/src/env.ts +++ b/src/env.ts @@ -9,8 +9,9 @@ export default createEnv({ client: {}, shared: { - NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + ALLOW_SEARCH_ENGINE_INDEXING: z.coerce.boolean().default(false), APP_BASE_URL: z.string().url().optional().default('http://localhost:3000'), + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), }, /** diff --git a/src/instrumentation.mjs b/src/instrumentation.mjs new file mode 100644 index 0000000..4fdedca --- /dev/null +++ b/src/instrumentation.mjs @@ -0,0 +1,17 @@ +/** + * @see: https://nextjs.org/docs/pages/building-your-application/optimizing/instrumentation + */ + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + const { registerInitialCache } = await import('@neshca/cache-handler/instrumentation') + + // Assuming that your CacheHandler configuration is in the root + // of the project and the instrumentation is in the src directory. + // Please adjust the path accordingly, CommonJS CacheHandler + // configuration is also supported. + const { default: CacheHandler } = await import('../server/cache-handler.mjs') + + await registerInitialCache(CacheHandler) + } +}