From 78accfd8b5a9a83b3c197ae61e858666faf19be2 Mon Sep 17 00:00:00 2001 From: James Anderson Date: Sun, 28 Jul 2024 17:37:49 +0100 Subject: [PATCH] feat: custom worker entrypoint (#828) --- .changeset/silly-coins-protect.md | 23 ++++++++ packages/next-on-pages/docs/advanced-usage.md | 34 +++++++++++ packages/next-on-pages/package.json | 9 ++- .../src/buildApplication/buildApplication.ts | 22 ++++++-- .../src/buildApplication/buildWorkerFile.ts | 56 +++++++++++++++---- packages/next-on-pages/src/cli.ts | 5 ++ .../next-on-pages/src/fetch-handler/index.ts | 9 +++ .../next-on-pages/tsconfig.fetch-handler.json | 10 ++++ .../assets/custom-entrypoint.ts | 11 ++++ .../custom-entrypoint.test.ts | 12 ++++ .../features/customEntrypoint/main.feature | 3 + pages-e2e/features/customEntrypoint/setup.ts | 2 + pages-e2e/fixtures/app13.4.0/app/layout.tsx | 5 +- pages-e2e/fixtures/app14.0.0/app/layout.tsx | 5 +- pages-e2e/fixtures/appLatest/main.fixture | 9 +-- .../pagesIssue578/pages/[[...path]].tsx | 5 +- 16 files changed, 184 insertions(+), 36 deletions(-) create mode 100644 .changeset/silly-coins-protect.md create mode 100644 packages/next-on-pages/docs/advanced-usage.md create mode 100644 packages/next-on-pages/src/fetch-handler/index.ts create mode 100644 packages/next-on-pages/tsconfig.fetch-handler.json create mode 100644 pages-e2e/features/customEntrypoint/assets/custom-entrypoint.ts create mode 100644 pages-e2e/features/customEntrypoint/custom-entrypoint.test.ts create mode 100644 pages-e2e/features/customEntrypoint/main.feature create mode 100644 pages-e2e/features/customEntrypoint/setup.ts diff --git a/.changeset/silly-coins-protect.md b/.changeset/silly-coins-protect.md new file mode 100644 index 000000000..d4fca8145 --- /dev/null +++ b/.changeset/silly-coins-protect.md @@ -0,0 +1,23 @@ +--- +'@cloudflare/next-on-pages': minor +--- + +Add support for custom worker entrypoints. + +Example: + +```ts +import nextOnPagesHandler from '@cloudflare/next-on-pages/fetch-handler'; + +export default { + async fetch(request, env, ctx) { + // do something before running the next-on-pages handler + + const response = await nextOnPagesHandler.fetch(request, env, ctx); + + // do something after running the next-on-pages handler + + return response; + }, +} as ExportedHandler<{ ASSETS: Fetcher }>; +``` diff --git a/packages/next-on-pages/docs/advanced-usage.md b/packages/next-on-pages/docs/advanced-usage.md new file mode 100644 index 000000000..35ad6ac20 --- /dev/null +++ b/packages/next-on-pages/docs/advanced-usage.md @@ -0,0 +1,34 @@ +# Advanced Usage + +## Custom Worker Entrypoint + +Certain use cases may require the ability the control what happens in your Pages project's worker. Observability requirements, for instance, might benefit from being able to intercept console logs, catch uncaught exceptions, or monitor the time spent doing work in the next-on-pages router. + +All of these would require modifying the worker to add some code before and/or after next-on-pages' logic runs. + +To achieve this, next-on-pages exposes an option to use your own worker entrypoint. Within it, you can directly import and use the next-on-pages fetch handler. + +1. Create a handler in your project. + +```ts +// file: ./custom-entrypoint.ts +import nextOnPagesHandler from '@cloudflare/next-on-pages/fetch-handler'; + +export default { + async fetch(request, env, ctx) { + // do something before running the next-on-pages handler + + const response = await nextOnPagesHandler.fetch(request, env, ctx); + + // do something after running the next-on-pages handler + + return response; + }, +} as ExportedHandler<{ ASSETS: Fetcher }>; +``` + +2. Pass the entrypoint argument to the next-on-pages CLI with the path to your handler. + +```sh +npx @cloudflare/next-on-pages --custom-entrypoint=./custom-entrypoint.ts +``` diff --git a/packages/next-on-pages/package.json b/packages/next-on-pages/package.json index f124f8e9e..e692d3742 100644 --- a/packages/next-on-pages/package.json +++ b/packages/next-on-pages/package.json @@ -7,6 +7,10 @@ "import": "./dist/api/index.js", "types": "./dist/api/index.d.ts" }, + "./fetch-handler": { + "import": "./dist/fetch-handler/index.js", + "types": "./dist/fetch-handler/index.d.ts" + }, "./next-dev": { "import": "./dist/next-dev/index.cjs", "require": "./dist/next-dev/index.cjs", @@ -16,12 +20,13 @@ "scripts": { "lint": "eslint src templates", "types-check": "tsc --noEmit", - "build:types": "tsc -p tsconfig.api.json", + "build:types": "tsc -p tsconfig.api.json -p tsconfig.fetch-handler.json", "build": "esbuild --bundle --platform=node ./src/index.ts ./src/api/index.ts --external:esbuild --external:chokidar --external:server-only --outdir=./dist", "build:watch": "npm run build -- --watch=forever", "build:no-nodejs-compat-error-page": "node ./build-no-nodejs-compat-flag-static-error-page.mjs", "build:next-dev": "npm run build --workspace @cloudflare/next-on-pages-next-dev && rm -rf ./dist/next-dev && cp -R ../../internal-packages/next-dev/dist ./dist/next-dev", - "postbuild": "npm run build:types && npm run build:no-nodejs-compat-error-page && npm run build:next-dev", + "build:fetch-handler": "esbuild --bundle --platform=browser ./src/fetch-handler/index.ts --external:server-only --outdir=./dist/fetch-handler", + "postbuild": "npm run build:types && npm run build:no-nodejs-compat-error-page && npm run build:next-dev && npm run build:fetch-handler", "prepare": "npm run build", "test:unit": "npx vitest --config vitest.config.ts" }, diff --git a/packages/next-on-pages/src/buildApplication/buildApplication.ts b/packages/next-on-pages/src/buildApplication/buildApplication.ts index ee7881044..92741382b 100644 --- a/packages/next-on-pages/src/buildApplication/buildApplication.ts +++ b/packages/next-on-pages/src/buildApplication/buildApplication.ts @@ -29,6 +29,7 @@ export async function buildApplication({ disableWorkerMinification, watch, outdir: outputDir, + customEntrypoint, }: Pick< CliOptions, | 'skipBuild' @@ -36,6 +37,7 @@ export async function buildApplication({ | 'disableWorkerMinification' | 'watch' | 'outdir' + | 'customEntrypoint' >) { const pm = await getPackageManager(); @@ -84,6 +86,7 @@ export async function buildApplication({ await prepareAndBuildWorker(outputDir, { disableChunksDedup, disableWorkerMinification, + customEntrypoint, }); const totalBuildTime = ((Date.now() - buildStartTime) / 1000).toFixed(2); @@ -95,7 +98,11 @@ async function prepareAndBuildWorker( { disableChunksDedup, disableWorkerMinification, - }: Pick, + customEntrypoint, + }: Pick< + CliOptions, + 'disableChunksDedup' | 'disableWorkerMinification' | 'customEntrypoint' + >, ): Promise { let vercelConfig: VercelConfig; try { @@ -140,11 +147,14 @@ async function prepareAndBuildWorker( processedFunctions?.collectedFunctions?.edgeFunctions, ); - const outputtedWorkerPath = await buildWorkerFile( - processedVercelOutput, - { outputDir, workerJsDir, nopDistDir, templatesDir }, - !disableWorkerMinification, - ); + const outputtedWorkerPath = await buildWorkerFile(processedVercelOutput, { + outputDir, + workerJsDir, + nopDistDir, + templatesDir, + customEntrypoint, + minify: !disableWorkerMinification, + }); await buildMetadataFiles(outputDir, { staticAssets }); diff --git a/packages/next-on-pages/src/buildApplication/buildWorkerFile.ts b/packages/next-on-pages/src/buildApplication/buildWorkerFile.ts index aa596fcb9..2d01843cd 100644 --- a/packages/next-on-pages/src/buildApplication/buildWorkerFile.ts +++ b/packages/next-on-pages/src/buildApplication/buildWorkerFile.ts @@ -6,6 +6,7 @@ import { generateGlobalJs } from './generateGlobalJs'; import type { ProcessedVercelOutput } from './processVercelOutput'; import { getNodeEnv } from '../utils/getNodeEnv'; import { normalizePath } from '../utils'; +import { cliLog } from '../cli'; /** * Construct a record for the build output map. @@ -41,8 +42,14 @@ export function constructBuildOutputRecord( export async function buildWorkerFile( { vercelConfig, vercelOutput }: ProcessedVercelOutput, - { outputDir, workerJsDir, nopDistDir, templatesDir }: BuildWorkerFileOpts, - minify: boolean, + { + outputDir, + workerJsDir, + nopDistDir, + templatesDir, + customEntrypoint, + minify, + }: BuildWorkerFileOpts, ): Promise { const functionsFile = join( tmpdir(), @@ -59,17 +66,21 @@ export async function buildWorkerFile( .join(',')}};`, ); + const defaultBuildOpts = { + target: 'es2022', + platform: 'neutral', + bundle: false, + minify, + } as const; + const outputFile = join(workerJsDir, 'index.js'); await build({ + ...defaultBuildOpts, entryPoints: [join(templatesDir, '_worker.js')], - banner: { - js: generateGlobalJs(), - }, + banner: { js: generateGlobalJs() }, bundle: true, inject: [functionsFile], - target: 'es2022', - platform: 'neutral', external: ['node:*', './__next-on-pages-dist__/*', 'cloudflare:*'], define: { __CONFIG__: JSON.stringify(vercelConfig), @@ -79,20 +90,39 @@ export async function buildWorkerFile( }), }, outfile: outputFile, - minify, }); await build({ + ...defaultBuildOpts, entryPoints: ['adaptor.ts', 'cache-api.ts', 'kv.ts'].map(fileName => join(templatesDir, 'cache', fileName), ), - bundle: false, - target: 'es2022', - platform: 'neutral', outdir: join(nopDistDir, 'cache'), - minify, }); + if (customEntrypoint) { + cliLog(`Using custom worker entrypoint '${customEntrypoint}'`); + + await build({ + ...defaultBuildOpts, + entryPoints: [customEntrypoint], + outfile: outputFile, + allowOverwrite: true, + bundle: true, + plugins: [ + { + name: 'custom-entrypoint-import-plugin', + setup(build) { + build.onResolve( + { filter: /^@cloudflare\/next-on-pages\/fetch-handler$/ }, + () => ({ path: outputFile }), + ); + }, + }, + ], + }); + } + return relative('.', outputFile); } @@ -101,6 +131,8 @@ type BuildWorkerFileOpts = { workerJsDir: string; nopDistDir: string; templatesDir: string; + customEntrypoint?: string; + minify?: boolean; }; /** diff --git a/packages/next-on-pages/src/cli.ts b/packages/next-on-pages/src/cli.ts index 29463d8e5..277d2ce94 100644 --- a/packages/next-on-pages/src/cli.ts +++ b/packages/next-on-pages/src/cli.ts @@ -58,6 +58,10 @@ program 'Sets the directory to output the worker and static assets to', join('.vercel', 'output', 'static'), ) + .option( + '--custom-entrypoint ', + 'Wrap the generated worker for your application in a custom worker entrypoint', + ) .enablePositionalOptions(false) .version( nextOnPagesVersion, @@ -74,6 +78,7 @@ export type CliOptions = { noColor?: boolean; info?: boolean; outdir: string; + customEntrypoint?: string; }; export function parseCliArgs(): CliOptions { diff --git a/packages/next-on-pages/src/fetch-handler/index.ts b/packages/next-on-pages/src/fetch-handler/index.ts new file mode 100644 index 000000000..f3d6a12b9 --- /dev/null +++ b/packages/next-on-pages/src/fetch-handler/index.ts @@ -0,0 +1,9 @@ +import 'server-only'; + +export default { + async fetch() { + throw new Error( + 'Invalid invocation of the next-on-pages fetch handler - this method should only be used alongside the --custom-entrypoint CLI option. For more details, see: https://github.com/cloudflare/next-on-pages/blob/main/packages/next-on-pages/docs/advanced-usage.md#custom-entrypoint', + ); + }, +} as { fetch: ExportedHandlerFetchHandler<{ ASSETS: Fetcher }> }; diff --git a/packages/next-on-pages/tsconfig.fetch-handler.json b/packages/next-on-pages/tsconfig.fetch-handler.json new file mode 100644 index 000000000..f4eb8310b --- /dev/null +++ b/packages/next-on-pages/tsconfig.fetch-handler.json @@ -0,0 +1,10 @@ +{ + "extends": "@cloudflare/next-on-pages-tsconfig/tsconfig.json", + "include": ["src/fetch-handler"], + "compilerOptions": { + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist/fetch-handler" + } +} diff --git a/pages-e2e/features/customEntrypoint/assets/custom-entrypoint.ts b/pages-e2e/features/customEntrypoint/assets/custom-entrypoint.ts new file mode 100644 index 000000000..991dfa56a --- /dev/null +++ b/pages-e2e/features/customEntrypoint/assets/custom-entrypoint.ts @@ -0,0 +1,11 @@ +import nextOnPagesHandler from '@cloudflare/next-on-pages/fetch-handler'; + +export default { + async fetch(...args) { + const response = await nextOnPagesHandler.fetch(...args); + + response.headers.set('custom-entrypoint', '1'); + + return response; + }, +} as ExportedHandler<{ ASSETS: Fetcher }>; diff --git a/pages-e2e/features/customEntrypoint/custom-entrypoint.test.ts b/pages-e2e/features/customEntrypoint/custom-entrypoint.test.ts new file mode 100644 index 000000000..5829ac31b --- /dev/null +++ b/pages-e2e/features/customEntrypoint/custom-entrypoint.test.ts @@ -0,0 +1,12 @@ +import { describe, test } from 'vitest'; + +describe('Custom Entrypoint', () => { + test('should set header on response in the worker entrypoint', async ({ + expect, + }) => { + const response = await fetch(`${DEPLOYMENT_URL}/api/hello`); + + await expect(response.text()).resolves.toEqual('Hello world'); + expect(response.headers.get('custom-entrypoint')).toEqual('1'); + }); +}); diff --git a/pages-e2e/features/customEntrypoint/main.feature b/pages-e2e/features/customEntrypoint/main.feature new file mode 100644 index 000000000..223c7e01e --- /dev/null +++ b/pages-e2e/features/customEntrypoint/main.feature @@ -0,0 +1,3 @@ +{ + "setup": "node --loader tsm setup.ts" +} diff --git a/pages-e2e/features/customEntrypoint/setup.ts b/pages-e2e/features/customEntrypoint/setup.ts new file mode 100644 index 000000000..d38a9cf1c --- /dev/null +++ b/pages-e2e/features/customEntrypoint/setup.ts @@ -0,0 +1,2 @@ +import { copyWorkspaceAssets } from '../_utils/copyWorkspaceAssets'; +await copyWorkspaceAssets(); diff --git a/pages-e2e/fixtures/app13.4.0/app/layout.tsx b/pages-e2e/fixtures/app13.4.0/app/layout.tsx index 2b0ebf0e5..617ef2912 100644 --- a/pages-e2e/fixtures/app13.4.0/app/layout.tsx +++ b/pages-e2e/fixtures/app13.4.0/app/layout.tsx @@ -1,7 +1,4 @@ import './globals.css'; -import { Inter } from 'next/font/google'; - -const inter = Inter({ subsets: ['latin'] }); export const metadata = { title: 'Create Next App', @@ -15,7 +12,7 @@ export default function RootLayout({ }) { return ( - {children} + {children} ); } diff --git a/pages-e2e/fixtures/app14.0.0/app/layout.tsx b/pages-e2e/fixtures/app14.0.0/app/layout.tsx index 97635d0ae..7186c23bb 100644 --- a/pages-e2e/fixtures/app14.0.0/app/layout.tsx +++ b/pages-e2e/fixtures/app14.0.0/app/layout.tsx @@ -1,9 +1,6 @@ import type { Metadata } from 'next'; -import { Inter } from 'next/font/google'; import './globals.css'; -const inter = Inter({ subsets: ['latin'] }); - export const metadata: Metadata = { title: 'Create Next App', description: 'Generated by create next app', @@ -16,7 +13,7 @@ export default function RootLayout({ }) { return ( - {children} + {children} ); } diff --git a/pages-e2e/fixtures/appLatest/main.fixture b/pages-e2e/fixtures/appLatest/main.fixture index f95b9f12d..acb573ce9 100644 --- a/pages-e2e/fixtures/appLatest/main.fixture +++ b/pages-e2e/fixtures/appLatest/main.fixture @@ -10,19 +10,20 @@ "appConfigsRewritesRedirectsHeaders", "appWasm", "appServerActions", - "appGetRequestContext" + "appGetRequestContext", + "customEntrypoint" ], "localSetup": "./setup.sh", "buildConfig": { - "buildCommand": "npx --force ../../../packages/next-on-pages", + "buildCommand": "npx --force ../../../packages/next-on-pages --custom-entrypoint=./custom-entrypoint.ts", "buildOutputDirectory": ".vercel/output/static" }, "deploymentConfig": { "compatibilityFlags": ["nodejs_compat"], "kvNamespaces": { "MY_KV": { - "production": {"id": "00000000000000000000000000000000"}, - "staging": {"id": "00000000000000000000000000000000"} + "production": { "id": "00000000000000000000000000000000" }, + "staging": { "id": "00000000000000000000000000000000" } } } } diff --git a/pages-e2e/fixtures/pagesIssue578/pages/[[...path]].tsx b/pages-e2e/fixtures/pagesIssue578/pages/[[...path]].tsx index 7f7590f33..0ee41e9ba 100644 --- a/pages-e2e/fixtures/pagesIssue578/pages/[[...path]].tsx +++ b/pages-e2e/fixtures/pagesIssue578/pages/[[...path]].tsx @@ -1,10 +1,7 @@ import Head from 'next/head'; import Image from 'next/image'; -import { Inter } from 'next/font/google'; import styles from '@/styles/Home.module.css'; -const inter = Inter({ subsets: ['latin'] }); - export default function Home() { return ( <> @@ -14,7 +11,7 @@ export default function Home() { -
+

[[...path]].tsx Page