Skip to content

Commit

Permalink
improve bundled assets handling (#325)
Browse files Browse the repository at this point in the history
  • Loading branch information
dario-piotrowicz authored Jul 14, 2023
1 parent 76a8bb4 commit 619beea
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 5 deletions.
21 changes: 21 additions & 0 deletions .changeset/clean-experts-tap.md
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ export async function generateFunctionsMap(
);
}

if (processingResults.bundledAssetsInfo.size) {
await copyBundledAssetFiles(
nextOnPagesDistDir,
processingResults.bundledAssetsInfo,
);
}

return processingResults;
}

Expand Down Expand Up @@ -139,6 +146,11 @@ async function tryToFixInvalidFunctions({
}
}

type BundledAssetInfo = {
filename: string;
originalFileLocation: string;
};

type WasmModuleInfo = {
identifier: string;
importPath: string;
Expand All @@ -155,6 +167,7 @@ async function processDirectoryRecursively(
const prerenderedRoutes = new Map<string, PrerenderedFileData>();
const wasmIdentifiers = new Map<string, WasmModuleInfo>();
const nextJsManifests = new Map<string, string>();
const bundledAssetsInfo = new Map<string, BundledAssetInfo>();

const files = await readdir(dir);
const functionFiles = await fixPrerenderedRoutes(
Expand Down Expand Up @@ -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),
);
}
}

Expand All @@ -200,6 +216,7 @@ async function processDirectoryRecursively(
prerenderedRoutes,
wasmIdentifiers,
nextJsManifests,
bundledAssetsInfo,
};
}

Expand All @@ -216,12 +233,12 @@ type FunctionConfig = {

async function processFuncDirectory(
setup: ProcessingSetup,
filepath: string,
directoryFilepath: string,
): Promise<Partial<DirectoryProcessingResults>> {
const relativePath = relative(setup.functionsDir, filepath);
const relativePath = relative(setup.functionsDir, directoryFilepath);

const functionConfig = await readJsonFile<FunctionConfig>(
join(filepath, '.vc-config.json'),
join(directoryFilepath, '.vc-config.json'),
);

if (functionConfig?.runtime !== 'edge') {
Expand All @@ -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.
Expand Down Expand Up @@ -291,6 +308,20 @@ async function processFuncDirectory(
contents = wasmExtractionResult.updatedContents;
wasmIdentifiers = wasmExtractionResult.wasmIdentifiers;

const bundledAssetsInfo = new Map<string, BundledAssetInfo>();

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 =
Expand Down Expand Up @@ -322,6 +353,7 @@ async function processFuncDirectory(
webpackChunks,
wasmIdentifiers,
nextJsManifests,
bundledAssetsInfo,
};
}

Expand Down Expand Up @@ -635,6 +667,7 @@ export type DirectoryProcessingResults = {
prerenderedRoutes: Map<string, PrerenderedFileData>;
wasmIdentifiers: Map<string, WasmModuleInfo>;
nextJsManifests: Map<string, string>;
bundledAssetsInfo: Map<string, BundledAssetInfo>;
};

/**
Expand Down Expand Up @@ -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<string, BundledAssetInfo>,
): Promise<void> {
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion packages/next-on-pages/templates/_worker.js/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,6 +14,8 @@ declare const __BUILD_OUTPUT__: VercelBuildOutput;

declare const __ENV_ALS_PROMISE__: Promise<null | AsyncLocalStorage<unknown>>;

patchFetchToAllowBundledAssets();

export default {
async fetch(request, env, ctx) {
const envAsyncLocalStorage = await __ENV_ALS_PROMISE__;
Expand Down
79 changes: 79 additions & 0 deletions packages/next-on-pages/templates/_worker.js/utils/fetch.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> | 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);
};
}
1 change: 1 addition & 0 deletions packages/next-on-pages/templates/_worker.js/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './http';
export * from './pcre';
export * from './routing';
export * from './images';
export * from './fetch';
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down

0 comments on commit 619beea

Please sign in to comment.