From c91e2575dd5b5faee6d9cb67f92def5171462015 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 12 Jun 2024 20:30:25 +0200 Subject: [PATCH] fix: update in-memory prerender manifest with information from full route cache (#579) * test: nonprerendered page and sanboxed lambda invocations * fix: update prerender manifest with information from full route cache * fix: use serverDistDir from options passed to CacheHandler to figure out prerender-manifest.json path * chore: fix windows specific things --------- Co-authored-by: Philippe Serhal --- src/build/content/prerendered.ts | 6 +- src/run/handlers/cache.cts | 68 +++++++++++++++--- src/shared/cache-types.cts | 16 ++++- tests/integration/cache-handler.test.ts | 47 ++++++++++++- tests/utils/fixture.ts | 47 +++++++++++++ tests/utils/sandbox-child.mjs | 92 +++++++++++++++++++++++++ 6 files changed, 258 insertions(+), 18 deletions(-) create mode 100644 tests/utils/sandbox-child.mjs diff --git a/src/build/content/prerendered.ts b/src/build/content/prerendered.ts index 51df6781e4..eecb326912 100644 --- a/src/build/content/prerendered.ts +++ b/src/build/content/prerendered.ts @@ -10,7 +10,7 @@ import pLimit from 'p-limit' import { encodeBlobKey } from '../../shared/blobkey.js' import type { CachedFetchValue, - CachedPageValue, + NetlifyCachedPageValue, NetlifyCachedRouteValue, NetlifyCacheHandlerValue, NetlifyIncrementalCacheValue, @@ -42,7 +42,7 @@ const writeCacheEntry = async ( */ const routeToFilePath = (path: string) => (path === '/' ? '/index' : path) -const buildPagesCacheValue = async (path: string): Promise => ({ +const buildPagesCacheValue = async (path: string): Promise => ({ kind: 'PAGE', html: await readFile(`${path}.html`, 'utf-8'), pageData: JSON.parse(await readFile(`${path}.json`, 'utf-8')), @@ -51,7 +51,7 @@ const buildPagesCacheValue = async (path: string): Promise => ( status: undefined, }) -const buildAppCacheValue = async (path: string): Promise => { +const buildAppCacheValue = async (path: string): Promise => { const meta = JSON.parse(await readFile(`${path}.meta`, 'utf-8')) const rsc = await readFile(`${path}.rsc`, 'utf-8').catch(() => readFile(`${path}.prefetch.rsc`, 'utf-8'), diff --git a/src/run/handlers/cache.cts b/src/run/handlers/cache.cts index 72f55e5b70..ff3bd60db5 100644 --- a/src/run/handlers/cache.cts +++ b/src/run/handlers/cache.cts @@ -2,16 +2,22 @@ // (CJS format because Next.js doesn't support ESM yet) // import { Buffer } from 'node:buffer' +import { join } from 'node:path' +import { join as posixJoin } from 'node:path/posix' import { Store } from '@netlify/blobs' import { purgeCache } from '@netlify/functions' import { type Span } from '@opentelemetry/api' +import type { PrerenderManifest } from 'next/dist/build/index.js' import { NEXT_CACHE_TAGS_HEADER } from 'next/dist/lib/constants.js' +import { loadManifest } from 'next/dist/server/load-manifest.js' +import { normalizePagePath } from 'next/dist/shared/lib/page-path/normalize-page-path.js' import type { CacheHandler, CacheHandlerContext, IncrementalCache, + NetlifyCachedPageValue, NetlifyCachedRouteValue, NetlifyCacheHandlerValue, NetlifyIncrementalCacheValue, @@ -105,6 +111,26 @@ export class NetlifyCacheHandler implements CacheHandler { return restOfRouteValue } + private injectEntryToPrerenderManifest( + key: string, + revalidate: NetlifyCachedPageValue['revalidate'], + ) { + if (this.options.serverDistDir && (typeof revalidate === 'number' || revalidate === false)) { + const prerenderManifest = loadManifest( + join(this.options.serverDistDir, '..', 'prerender-manifest.json'), + ) as PrerenderManifest + + prerenderManifest.routes[key] = { + experimentalPPR: undefined, + dataRoute: posixJoin('/_next/data', `${normalizePagePath(key)}.json`), + srcRoute: null, // FIXME: provide actual source route, however, when dynamically appending it doesn't really matter + initialRevalidateSeconds: revalidate, + // Pages routes do not have a prefetch data route. + prefetchDataRoute: undefined, + } + } + } + async get(...args: Parameters): ReturnType { return this.tracer.withActiveSpan('get cache key', async (span) => { const [key, ctx = {}] = args @@ -156,12 +182,18 @@ export class NetlifyCacheHandler implements CacheHandler { }, } } - case 'PAGE': + case 'PAGE': { span.addEvent('PAGE', { lastModified: blob.lastModified }) + + const { revalidate, ...restOfPageValue } = blob.value + + this.injectEntryToPrerenderManifest(key, revalidate) + return { lastModified: blob.lastModified, - value: blob.value, + value: restOfPageValue, } + } default: span.recordException(new Error(`Unknown cache entry kind: ${blob.value?.kind}`)) } @@ -169,6 +201,28 @@ export class NetlifyCacheHandler implements CacheHandler { }) } + private transformToStorableObject( + data: Parameters[1], + context: Parameters[2], + ): NetlifyIncrementalCacheValue | null { + if (data?.kind === 'ROUTE') { + return { + ...data, + revalidate: context.revalidate, + body: data.body.toString('base64'), + } + } + + if (data?.kind === 'PAGE') { + return { + ...data, + revalidate: context.revalidate, + } + } + + return data + } + async set(...args: Parameters) { return this.tracer.withActiveSpan('set cache key', async (span) => { const [key, data, context] = args @@ -178,15 +232,7 @@ export class NetlifyCacheHandler implements CacheHandler { getLogger().debug(`[NetlifyCacheHandler.set]: ${key}`) - const value: NetlifyIncrementalCacheValue | null = - data?.kind === 'ROUTE' - ? // don't mutate data, as it's used for the initial response - instead create a new object - { - ...data, - revalidate: context.revalidate, - body: data.body.toString('base64'), - } - : data + const value = this.transformToStorableObject(data, context) await this.blobStore.setJSON(blobKey, { lastModified, diff --git a/src/shared/cache-types.cts b/src/shared/cache-types.cts index 4621b1cc15..63d2f3a809 100644 --- a/src/shared/cache-types.cts +++ b/src/shared/cache-types.cts @@ -21,14 +21,24 @@ export type NetlifyCachedRouteValue = Omit & { revalidate: Parameters[2]['revalidate'] } -export type CachedPageValue = Extract +type CachedPageValue = Extract + +export type NetlifyCachedPageValue = CachedPageValue & { + revalidate?: Parameters[2]['revalidate'] +} + export type CachedFetchValue = Extract export type NetlifyIncrementalCacheValue = - | Exclude + | Exclude | NetlifyCachedRouteValue + | NetlifyCachedPageValue -type CachedRouteValueToNetlify = T extends CachedRouteValue ? NetlifyCachedRouteValue : T +type CachedRouteValueToNetlify = T extends CachedRouteValue + ? NetlifyCachedRouteValue + : T extends CachedPageValue + ? NetlifyCachedPageValue + : T type MapCachedRouteValueToNetlify = { [K in keyof T]: CachedRouteValueToNetlify } export type NetlifyCacheHandlerValue = MapCachedRouteValueToNetlify diff --git a/tests/integration/cache-handler.test.ts b/tests/integration/cache-handler.test.ts index 0c4227da06..a1945fa66a 100644 --- a/tests/integration/cache-handler.test.ts +++ b/tests/integration/cache-handler.test.ts @@ -3,7 +3,12 @@ import { getLogger } from 'lambda-local' import { v4 } from 'uuid' import { beforeEach, describe, expect, test, vi } from 'vitest' import { type FixtureTestContext } from '../utils/contexts.js' -import { createFixture, invokeFunction, runPlugin } from '../utils/fixture.js' +import { + createFixture, + invokeFunction, + invokeSandboxedFunction, + runPlugin, +} from '../utils/fixture.js' import { countOfBlobServerGetsForKey, decodeBlobKey, @@ -299,6 +304,46 @@ describe('app router', () => { ).toBe(1) ctx.blobServerGetSpy.mockClear() }) + + test("not-prerendered pages should be permanently cached when produced by sandboxed invocations that don't share memory", async (ctx) => { + await createFixture('server-components', ctx) + await runPlugin(ctx) + + const blobEntries = await getBlobEntries(ctx) + // dynamic route that is not pre-rendered should NOT be in the blob store (this is to ensure that test setup is correct) + expect(blobEntries.map(({ key }) => decodeBlobKey(key))).not.toContain('/static-fetch/3') + + // there is no pre-rendered page for this route, so it should result in a cache miss and blocking render + const call1 = await invokeSandboxedFunction(ctx, { url: '/static-fetch/3' }) + expect( + call1.headers['cache-status'], + 'Page should not be in cache yet as this is first time it is being generated', + ).toBe('"Next.js"; fwd=miss') + + const call1Date = load(call1.body)('[data-testid="date-now"]').text() + + await new Promise((res) => setTimeout(res, 5000)) + + const call2 = await invokeSandboxedFunction(ctx, { url: '/static-fetch/3' }) + expect( + call2.headers['cache-status'], + 'Page should be permanently cached after initial render', + ).toBe('"Next.js"; hit') + + const call2Date = load(call2.body)('[data-testid="date-now"]').text() + expect(call2Date, 'Content of response should match').toBe(call1Date) + + await new Promise((res) => setTimeout(res, 5000)) + + const call3 = await invokeSandboxedFunction(ctx, { url: '/static-fetch/3' }) + expect( + call3.headers['cache-status'], + 'Page should be permanently cached after initial render', + ).toBe('"Next.js"; hit') + const call3Date = load(call3.body)('[data-testid="date-now"]').text() + + expect(call3Date, 'Content of response should match').toBe(call2Date) + }) }) describe('plugin', () => { diff --git a/tests/utils/fixture.ts b/tests/utils/fixture.ts index b783d97fc8..44931bec94 100644 --- a/tests/utils/fixture.ts +++ b/tests/utils/fixture.ts @@ -7,6 +7,7 @@ import { zipFunctions } from '@netlify/zip-it-and-ship-it' import { execaCommand } from 'execa' import getPort from 'get-port' import { execute } from 'lambda-local' +import { spawn } from 'node:child_process' import { createWriteStream, existsSync } from 'node:fs' import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -449,3 +450,49 @@ export async function invokeEdgeFunction( }, }) } + +export async function invokeSandboxedFunction( + ctx: FixtureTestContext, + options: Parameters[1] = {}, +) { + return new Promise>((resolve, reject) => { + const childProcess = spawn(process.execPath, [import.meta.dirname + '/sandbox-child.mjs'], { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + cwd: process.cwd(), + }) + + childProcess.stdout?.on('data', (data) => { + console.log(data.toString()) + }) + + childProcess.stderr?.on('data', (data) => { + console.error(data.toString()) + }) + + childProcess.on('message', (msg: any) => { + if (msg?.action === 'invokeFunctionResult') { + resolve(msg.result) + childProcess.send({ action: 'exit' }) + } + }) + + childProcess.on('exit', () => { + reject(new Error('worker exited before returning result')) + }) + + childProcess.send({ + action: 'invokeFunction', + args: [ + // context object is not serializable so we create serializable object + // containing required properties to invoke lambda + { + functionDist: ctx.functionDist, + blobStoreHost: ctx.blobStoreHost, + siteID: ctx.siteID, + deployID: ctx.deployID, + }, + options, + ], + }) + }) +} diff --git a/tests/utils/sandbox-child.mjs b/tests/utils/sandbox-child.mjs new file mode 100644 index 0000000000..a6371cbcd0 --- /dev/null +++ b/tests/utils/sandbox-child.mjs @@ -0,0 +1,92 @@ +import { Buffer } from 'node:buffer' +import { join } from 'node:path' + +import { execute, getLogger } from 'lambda-local' + +const SERVER_HANDLER_NAME = '___netlify-server-handler' +const BLOB_TOKEN = 'secret-token' + +getLogger().level = 'alert' + +const createBlobContext = (ctx) => + Buffer.from( + JSON.stringify({ + edgeURL: `http://${ctx.blobStoreHost}`, + uncachedEdgeURL: `http://${ctx.blobStoreHost}`, + token: BLOB_TOKEN, + siteID: ctx.siteID, + deployID: ctx.deployID, + primaryRegion: 'us-test-1', + }), + ).toString('base64') + +function streamToBuffer(stream) { + const chunks = [] + + return new Promise((resolve, reject) => { + stream.on('data', (chunk) => chunks.push(Buffer.from(chunk))) + stream.on('error', (err) => reject(err)) + stream.on('end', () => resolve(Buffer.concat(chunks))) + }) +} + +process.on('message', async (msg) => { + if (msg?.action === 'exit') { + process.exit(0) + } else if (msg?.action === 'invokeFunction') { + try { + const [ctx, options] = msg.args + const { httpMethod, headers, body, url, env } = options + + const { handler } = await import( + 'file:///' + join(ctx.functionDist, SERVER_HANDLER_NAME, '___netlify-entry-point.mjs') + ) + + const environment = { + NODE_ENV: 'production', + NETLIFY_BLOBS_CONTEXT: createBlobContext(ctx), + ...(env || {}), + } + + const response = await execute({ + event: { + headers: headers || {}, + httpMethod: httpMethod || 'GET', + rawUrl: new URL(url || '/', 'https://example.netlify').href, + }, + environment, + envdestroy: true, + lambdaFunc: { handler }, + timeoutMs: 4_000, + }) + + const responseHeaders = Object.entries(response.multiValueHeaders || {}).reduce( + (prev, [key, value]) => ({ + ...prev, + [key]: value.length === 1 ? `${value}` : value.join(', '), + }), + response.headers || {}, + ) + + const bodyBuffer = await streamToBuffer(response.body) + + const result = { + statusCode: response.statusCode, + bodyBuffer, + body: bodyBuffer.toString('utf-8'), + headers: responseHeaders, + isBase64Encoded: response.isBase64Encoded, + } + + if (process.send) { + process.send({ + action: 'invokeFunctionResult', + result, + }) + } + } catch (e) { + console.log('error', e) + process.exit(1) + } + } +})