From 619beea3357a6ac9800ebe971ad46d233dc4ccfd Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Fri, 14 Jul 2023 10:32:57 +0100 Subject: [PATCH] improve bundled assets handling (#325) --- .changeset/clean-experts-tap.md | 21 +++++ .../buildApplication/generateFunctionsMap.ts | 60 +++++++++++++- .../src/buildApplication/generateGlobalJs.ts | 5 ++ .../templates/_worker.js/index.ts | 8 +- .../templates/_worker.js/utils/fetch.ts | 79 +++++++++++++++++++ .../templates/_worker.js/utils/index.ts | 1 + .../buildApplication/generateGlobalJs.test.ts | 9 +++ 7 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 .changeset/clean-experts-tap.md create mode 100644 packages/next-on-pages/templates/_worker.js/utils/fetch.ts diff --git a/.changeset/clean-experts-tap.md b/.changeset/clean-experts-tap.md new file mode 100644 index 000000000..f861808da --- /dev/null +++ b/.changeset/clean-experts-tap.md @@ -0,0 +1,21 @@ +--- +'@cloudflare/next-on-pages': patch +--- + +bundle assets produced by the Vercel build and make them accessible via fetch + +Vercel/Next can allow access binary assets bundled with their edge functions in the following manner: + +``` +const font = fetch(new URL('../../assets/asset-x', import.meta.url)).then( + (res) => res.arrayBuffer(), +); +``` + +As you can see in this `@vercel/og` example: +https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation/og-image-examples#using-a-custom-font + +This sort of access to bindings is necessary for the `@vercel/og` package to work and might be used in other packages +as well, so it is something that we need to support. +We do so by making sure that we properly bind the assets found in the Vercel build output into our worker +and that fetches to such assets (using blob urls) are correctly handled (this requires us to patch the global `fetch` function) diff --git a/packages/next-on-pages/src/buildApplication/generateFunctionsMap.ts b/packages/next-on-pages/src/buildApplication/generateFunctionsMap.ts index dbd74b346..e5f2337f0 100644 --- a/packages/next-on-pages/src/buildApplication/generateFunctionsMap.ts +++ b/packages/next-on-pages/src/buildApplication/generateFunctionsMap.ts @@ -89,6 +89,13 @@ export async function generateFunctionsMap( ); } + if (processingResults.bundledAssetsInfo.size) { + await copyBundledAssetFiles( + nextOnPagesDistDir, + processingResults.bundledAssetsInfo, + ); + } + return processingResults; } @@ -139,6 +146,11 @@ async function tryToFixInvalidFunctions({ } } +type BundledAssetInfo = { + filename: string; + originalFileLocation: string; +}; + type WasmModuleInfo = { identifier: string; importPath: string; @@ -155,6 +167,7 @@ async function processDirectoryRecursively( const prerenderedRoutes = new Map(); const wasmIdentifiers = new Map(); const nextJsManifests = new Map(); + const bundledAssetsInfo = new Map(); const files = await readdir(dir); const functionFiles = await fixPrerenderedRoutes( @@ -190,6 +203,9 @@ async function processDirectoryRecursively( dirResults.nextJsManifests?.forEach((value, key) => nextJsManifests.set(key, value), ); + dirResults.bundledAssetsInfo?.forEach((value, key) => + bundledAssetsInfo.set(key, value), + ); } } @@ -200,6 +216,7 @@ async function processDirectoryRecursively( prerenderedRoutes, wasmIdentifiers, nextJsManifests, + bundledAssetsInfo, }; } @@ -216,12 +233,12 @@ type FunctionConfig = { async function processFuncDirectory( setup: ProcessingSetup, - filepath: string, + directoryFilepath: string, ): Promise> { - const relativePath = relative(setup.functionsDir, filepath); + const relativePath = relative(setup.functionsDir, directoryFilepath); const functionConfig = await readJsonFile( - join(filepath, '.vc-config.json'), + join(directoryFilepath, '.vc-config.json'), ); if (functionConfig?.runtime !== 'edge') { @@ -239,7 +256,7 @@ async function processFuncDirectory( functionConfig.entrypoint = 'index.js'; } - const functionFilePath = join(filepath, functionConfig.entrypoint); + const functionFilePath = join(directoryFilepath, functionConfig.entrypoint); if (!(await validateFile(functionFilePath))) { if (isMiddleware) { // We sometimes encounter an uncompiled `middleware.js` with no compiled `index.js` outside of a base path. @@ -291,6 +308,20 @@ async function processFuncDirectory( contents = wasmExtractionResult.updatedContents; wasmIdentifiers = wasmExtractionResult.wasmIdentifiers; + const bundledAssetsInfo = new Map(); + + const assetsDir = join(directoryFilepath, 'assets'); + const assetsDirExists = await validateDir(join(directoryFilepath, 'assets')); + if (assetsDirExists) { + const files = await readdir(assetsDir); + files.forEach(file => { + bundledAssetsInfo.set(file, { + filename: file, + originalFileLocation: join(assetsDir, file), + }); + }); + } + const newFilePath = join(setup.distFunctionsDir, `${relativePath}.js`); await mkdir(dirname(newFilePath), { recursive: true }); const relativeNextOnPagesDistPath = @@ -322,6 +353,7 @@ async function processFuncDirectory( webpackChunks, wasmIdentifiers, nextJsManifests, + bundledAssetsInfo, }; } @@ -635,6 +667,7 @@ export type DirectoryProcessingResults = { prerenderedRoutes: Map; wasmIdentifiers: Map; nextJsManifests: Map; + bundledAssetsInfo: Map; }; /** @@ -922,3 +955,22 @@ async function collectChunksConsumersInfosFromFuncDirectory( return chunksConsumersInfos; } + +/** + * Copies bundled asset files into the __next-on-pages-dist__/assets folder as binary files (so that they can be + * fetched as binaries) + * + * @param distDir the __next-on-pages-dist__ directory's path + * @param bundledAssetsInfo map containing all the bundled assets files info collected during the build process + */ +async function copyBundledAssetFiles( + distDir: string, + bundledAssetsInfo: Map, +): Promise { + const assetsDir = join(distDir, 'assets'); + await mkdir(assetsDir); + for (const { filename, originalFileLocation } of bundledAssetsInfo.values()) { + const newLocation = join(assetsDir, `${filename}.bin`); + await copyFile(originalFileLocation, newLocation); + } +} diff --git a/packages/next-on-pages/src/buildApplication/generateGlobalJs.ts b/packages/next-on-pages/src/buildApplication/generateGlobalJs.ts index 68591e8f9..f4c033fc0 100644 --- a/packages/next-on-pages/src/buildApplication/generateGlobalJs.ts +++ b/packages/next-on-pages/src/buildApplication/generateGlobalJs.ts @@ -6,6 +6,11 @@ */ export function generateGlobalJs(): string { return ` + import('node:buffer').then(({ Buffer }) => { + globalThis.Buffer = Buffer; + }) + .catch(() => null); + const __ENV_ALS_PROMISE__ = import('node:async_hooks').then(({ AsyncLocalStorage }) => { globalThis.AsyncLocalStorage = AsyncLocalStorage; diff --git a/packages/next-on-pages/templates/_worker.js/index.ts b/packages/next-on-pages/templates/_worker.js/index.ts index 6aa478a7f..fe28614f1 100644 --- a/packages/next-on-pages/templates/_worker.js/index.ts +++ b/packages/next-on-pages/templates/_worker.js/index.ts @@ -1,5 +1,9 @@ import { handleRequest } from './handleRequest'; -import { adjustRequestForVercel, handleImageResizingRequest } from './utils'; +import { + adjustRequestForVercel, + handleImageResizingRequest, + patchFetchToAllowBundledAssets, +} from './utils'; import type { AsyncLocalStorage } from 'node:async_hooks'; declare const __NODE_ENV__: string; @@ -10,6 +14,8 @@ declare const __BUILD_OUTPUT__: VercelBuildOutput; declare const __ENV_ALS_PROMISE__: Promise>; +patchFetchToAllowBundledAssets(); + export default { async fetch(request, env, ctx) { const envAsyncLocalStorage = await __ENV_ALS_PROMISE__; diff --git a/packages/next-on-pages/templates/_worker.js/utils/fetch.ts b/packages/next-on-pages/templates/_worker.js/utils/fetch.ts new file mode 100644 index 000000000..3611c4e24 --- /dev/null +++ b/packages/next-on-pages/templates/_worker.js/utils/fetch.ts @@ -0,0 +1,79 @@ +export function patchFetchToAllowBundledAssets(): void { + const flagSymbol = Symbol.for('next-on-pages bundled assets fetch patch'); + + const alreadyPatched = ( + globalThis.fetch as unknown as { [flagSymbol]: boolean } + )[flagSymbol]; + if (alreadyPatched) { + return; + } + + applyPatch(); + + (globalThis.fetch as unknown as { [flagSymbol]: boolean })[flagSymbol] = true; +} + +function applyPatch() { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (...args) => { + const request = new Request(...args); + + if (request.url.startsWith('blob:')) { + try { + const url = new URL(request.url); + const binaryContent = ( + await import(`./__next-on-pages-dist__/assets/${url.pathname}.bin`) + ).default; + + // Note: we can't generate a real Response object here because this fetch might be called + // at the top level of a dynamically imported module, and such cases produce the following + // error: + // Some functionality, such as asynchronous I/O, timeouts, and generating random values, + // can only be performed while handling a request + // this is a somewhat known workerd behavior (currently kept for security and performance reasons) + // + // if the above issue/constraint were to change we should replace the following with a real Response object + const resp = { + async arrayBuffer() { + return binaryContent; + }, + get body(): ReadableStream | null { + return new ReadableStream({ + start(controller) { + return pump(); + function pump() { + const b = Buffer.from(binaryContent); + controller.enqueue(b); + controller.close(); + return pump(); + } + }, + }); + }, + async text() { + const b = Buffer.from(binaryContent); + return b.toString(); + }, + async json() { + const b = Buffer.from(binaryContent); + return JSON.stringify(b.toString()); + }, + async blob() { + return new Blob(binaryContent); + }, + } as Response; + + // Note: clone is necessary so that body does work + resp.clone = (): Response => { + return resp; + }; + + return resp; + } catch { + /* empty, let's just fallback to the original fetch */ + } + } + + return originalFetch(request); + }; +} diff --git a/packages/next-on-pages/templates/_worker.js/utils/index.ts b/packages/next-on-pages/templates/_worker.js/utils/index.ts index df2a0ff3f..5f03e5e43 100644 --- a/packages/next-on-pages/templates/_worker.js/utils/index.ts +++ b/packages/next-on-pages/templates/_worker.js/utils/index.ts @@ -4,3 +4,4 @@ export * from './http'; export * from './pcre'; export * from './routing'; export * from './images'; +export * from './fetch'; diff --git a/packages/next-on-pages/tests/src/buildApplication/generateGlobalJs.test.ts b/packages/next-on-pages/tests/src/buildApplication/generateGlobalJs.test.ts index 136c25c2c..50306803e 100644 --- a/packages/next-on-pages/tests/src/buildApplication/generateGlobalJs.test.ts +++ b/packages/next-on-pages/tests/src/buildApplication/generateGlobalJs.test.ts @@ -17,6 +17,15 @@ describe('generateGlobalJs', async () => { ); }); + test('should make the Buffer globally available', async () => { + /* + Note: we need Buffer in the global scope + as it is sometimes used by Next under the hood + */ + const globalJs = generateGlobalJs(); + expect(globalJs).toContain('globalThis.Buffer = Buffer'); + }); + test('create an AsyncLocalStorage and set it as a proxy to process.env', async () => { const globalJs = generateGlobalJs(); expect(globalJs).toContain(