Skip to content

Commit

Permalink
fix: update in-memory prerender manifest with information from full r…
Browse files Browse the repository at this point in the history
…oute 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 <[email protected]>
  • Loading branch information
pieh and serhalp authored Jun 12, 2024
1 parent 9727c6b commit c91e257
Show file tree
Hide file tree
Showing 6 changed files with 258 additions and 18 deletions.
6 changes: 3 additions & 3 deletions src/build/content/prerendered.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import pLimit from 'p-limit'
import { encodeBlobKey } from '../../shared/blobkey.js'
import type {
CachedFetchValue,
CachedPageValue,
NetlifyCachedPageValue,
NetlifyCachedRouteValue,
NetlifyCacheHandlerValue,
NetlifyIncrementalCacheValue,
Expand Down Expand Up @@ -42,7 +42,7 @@ const writeCacheEntry = async (
*/
const routeToFilePath = (path: string) => (path === '/' ? '/index' : path)

const buildPagesCacheValue = async (path: string): Promise<CachedPageValue> => ({
const buildPagesCacheValue = async (path: string): Promise<NetlifyCachedPageValue> => ({
kind: 'PAGE',
html: await readFile(`${path}.html`, 'utf-8'),
pageData: JSON.parse(await readFile(`${path}.json`, 'utf-8')),
Expand All @@ -51,7 +51,7 @@ const buildPagesCacheValue = async (path: string): Promise<CachedPageValue> => (
status: undefined,
})

const buildAppCacheValue = async (path: string): Promise<CachedPageValue> => {
const buildAppCacheValue = async (path: string): Promise<NetlifyCachedPageValue> => {
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'),
Expand Down
68 changes: 57 additions & 11 deletions src/run/handlers/cache.cts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<CacheHandler['get']>): ReturnType<CacheHandler['get']> {
return this.tracer.withActiveSpan('get cache key', async (span) => {
const [key, ctx = {}] = args
Expand Down Expand Up @@ -156,19 +182,47 @@ 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}`))
}
return null
})
}

private transformToStorableObject(
data: Parameters<IncrementalCache['set']>[1],
context: Parameters<IncrementalCache['set']>[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<IncrementalCache['set']>) {
return this.tracer.withActiveSpan('set cache key', async (span) => {
const [key, data, context] = args
Expand All @@ -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,
Expand Down
16 changes: 13 additions & 3 deletions src/shared/cache-types.cts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,24 @@ export type NetlifyCachedRouteValue = Omit<CachedRouteValue, 'body'> & {
revalidate: Parameters<IncrementalCache['set']>[2]['revalidate']
}

export type CachedPageValue = Extract<IncrementalCacheValue, { kind: 'PAGE' }>
type CachedPageValue = Extract<IncrementalCacheValue, { kind: 'PAGE' }>

export type NetlifyCachedPageValue = CachedPageValue & {
revalidate?: Parameters<IncrementalCache['set']>[2]['revalidate']
}

export type CachedFetchValue = Extract<IncrementalCacheValue, { kind: 'FETCH' }>

export type NetlifyIncrementalCacheValue =
| Exclude<IncrementalCacheValue, CachedRouteValue>
| Exclude<IncrementalCacheValue, CachedRouteValue | CachedPageValue>
| NetlifyCachedRouteValue
| NetlifyCachedPageValue

type CachedRouteValueToNetlify<T> = T extends CachedRouteValue ? NetlifyCachedRouteValue : T
type CachedRouteValueToNetlify<T> = T extends CachedRouteValue
? NetlifyCachedRouteValue
: T extends CachedPageValue
? NetlifyCachedPageValue
: T
type MapCachedRouteValueToNetlify<T> = { [K in keyof T]: CachedRouteValueToNetlify<T[K]> }

export type NetlifyCacheHandlerValue = MapCachedRouteValueToNetlify<CacheHandlerValue>
47 changes: 46 additions & 1 deletion tests/integration/cache-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -299,6 +304,46 @@ describe('app router', () => {
).toBe(1)
ctx.blobServerGetSpy.mockClear()
})

test<FixtureTestContext>("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', () => {
Expand Down
47 changes: 47 additions & 0 deletions tests/utils/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -449,3 +450,49 @@ export async function invokeEdgeFunction(
},
})
}

export async function invokeSandboxedFunction(
ctx: FixtureTestContext,
options: Parameters<typeof invokeFunction>[1] = {},
) {
return new Promise<ReturnType<typeof invokeFunction>>((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,
],
})
})
}
92 changes: 92 additions & 0 deletions tests/utils/sandbox-child.mjs
Original file line number Diff line number Diff line change
@@ -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)
}
}
})

0 comments on commit c91e257

Please sign in to comment.