Skip to content

Commit 36a877b

Browse files
committed
initial isr
1 parent 9435972 commit 36a877b

File tree

8 files changed

+427
-81
lines changed

8 files changed

+427
-81
lines changed

playwright.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export default defineConfig({
2626
/* Add debug logging for netlify cache headers */
2727
'x-nf-debug-logging': '1',
2828
'x-next-debug-logging': '1',
29+
'x-nf-enable-tracing': '1',
2930
},
3031
},
3132
timeout: 10 * 60 * 1000,

src/adapter/build/netlify-adapter-context.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,49 @@
11
import type { FrameworksAPIConfig } from './types.js'
22

3+
type Revalidate = number | false
4+
5+
export type ISRCacheEntry = {
6+
/** body of initial response */
7+
content: string
8+
/**
9+
* initialStatus is the status code that should be applied
10+
* when serving the fallback
11+
*/
12+
status?: number
13+
/**
14+
* initialHeaders are the headers that should be sent when
15+
* serving the fallback
16+
*/
17+
headers?: Record<string, string>
18+
/**
19+
* initial expiration is how long until the fallback entry
20+
* is considered expired and no longer valid to serve
21+
*/
22+
expiration?: number
23+
/**
24+
* initial revalidate is how long until the fallback is
25+
* considered stale and should be revalidated
26+
*/
27+
revalidate?: Revalidate
28+
}
29+
30+
export type ISRDef = {
31+
pathname: string
32+
queryParams: string[]
33+
fallback?: ISRCacheEntry
34+
}
35+
336
export function createNetlifyAdapterContext() {
437
return {
538
frameworksAPIConfig: undefined as FrameworksAPIConfig | undefined,
639
preparedOutputs: {
740
staticAssets: [] as string[],
841
staticAssetsAliases: {} as Record<string, string>,
9-
endpoints: [] as string[],
42+
endpoints: {} as Record<
43+
string,
44+
{ entry: string; id: string } & ({ type: 'function' } | { type: 'isr'; isrGroup: number })
45+
>,
46+
isrGroups: {} as Record<number, ISRDef[]>,
1047
middleware: false,
1148
},
1249
}

src/adapter/build/pages-and-app-handlers.ts

Lines changed: 58 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { cp, mkdir, writeFile } from 'node:fs/promises'
1+
import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'
22
import { join, relative } from 'node:path/posix'
33

44
import type { InSourceConfig } from '@netlify/zip-it-and-ship-it/dist/runtimes/node/in_source_config/index.js'
@@ -41,7 +41,7 @@ export async function onBuildComplete(
4141
netlifyAdapterContext: NetlifyAdapterContext,
4242
) {
4343
const requiredFiles = new Set<string>()
44-
const pathnameToEntry: Record<string, string> = {}
44+
const { isrGroups, endpoints } = netlifyAdapterContext.preparedOutputs
4545

4646
for (const outputs of [
4747
nextAdapterContext.outputs.pages,
@@ -59,31 +59,59 @@ export async function onBuildComplete(
5959
}
6060

6161
requiredFiles.add(output.filePath)
62-
pathnameToEntry[normalizeIndex(output.pathname)] = relative(
63-
nextAdapterContext.repoRoot,
64-
output.filePath,
65-
)
62+
endpoints[normalizeIndex(output.pathname)] = {
63+
entry: relative(nextAdapterContext.repoRoot, output.filePath),
64+
id: normalizeIndex(output.pathname),
65+
type: 'function',
66+
}
6667
}
6768
}
6869

6970
for (const prerender of nextAdapterContext.outputs.prerenders) {
7071
const normalizedPathname = normalizeIndex(prerender.pathname)
7172
const normalizedParentOutputId = normalizeIndex(prerender.parentOutputId)
7273

73-
if (normalizedPathname in pathnameToEntry) {
74-
// console.log('Skipping prerender, already have route:', normalizedPathname)
75-
} else if (normalizedParentOutputId in pathnameToEntry) {
76-
// if we don't have routing for this route yet, add it
77-
// console.log('prerender mapping', {
78-
// from: normalizedPathname,
79-
// to: normalizedParentOutputId,
80-
// })
81-
pathnameToEntry[normalizedPathname] = pathnameToEntry[normalizedParentOutputId]
74+
const existingEntryForParent = endpoints[normalizedParentOutputId]
75+
76+
if (existingEntryForParent) {
77+
endpoints[normalizedPathname] = {
78+
...existingEntryForParent,
79+
id: normalizedPathname,
80+
type: 'isr',
81+
isrGroup: prerender.groupId,
82+
}
83+
84+
if (!isrGroups[prerender.groupId]) {
85+
isrGroups[prerender.groupId] = []
86+
}
87+
const isrGroup: (typeof isrGroups)[number][number] = {
88+
pathname: normalizedPathname,
89+
queryParams: prerender.config.allowQuery ?? [],
90+
}
91+
92+
if (prerender.fallback) {
93+
isrGroup.fallback = {
94+
content: await readFile(prerender.fallback.filePath, 'utf-8'),
95+
status: prerender.fallback.initialStatus,
96+
headers: prerender.fallback.initialHeaders
97+
? Object.fromEntries(
98+
Object.entries(prerender.fallback.initialHeaders).map(([key, value]) => [
99+
key,
100+
Array.isArray(value) ? value.join(',') : value,
101+
]),
102+
)
103+
: undefined,
104+
expiration: prerender.fallback.initialExpiration,
105+
revalidate: prerender.fallback.initialRevalidate,
106+
}
107+
}
108+
109+
isrGroups[prerender.groupId].push(isrGroup)
82110
} else {
83-
// console.warn('Could not find parent output for prerender:', {
84-
// pathname: normalizedPathname,
85-
// parentOutputId: normalizedParentOutputId,
86-
// })
111+
console.warn('Could not find parent output for prerender:', {
112+
pathname: normalizedPathname,
113+
parentOutputId: normalizedParentOutputId,
114+
})
87115
}
88116
}
89117

@@ -99,12 +127,14 @@ export async function onBuildComplete(
99127
)
100128
}
101129

102-
// copy needed runtime files
103-
104130
await copyRuntime(join(PAGES_AND_APP_FUNCTION_DIR, RUNTIME_DIR))
105131

132+
const normalizedPathsForFunctionConfig = Object.keys(endpoints).map((pathname) =>
133+
pathname.toLowerCase(),
134+
)
135+
106136
const functionConfig = {
107-
path: Object.keys(pathnameToEntry).map((pathname) => pathname.toLowerCase()),
137+
path: normalizedPathsForFunctionConfig,
108138
nodeBundler: 'none',
109139
includedFiles: ['**'],
110140
generator: GENERATOR,
@@ -113,45 +143,18 @@ export async function onBuildComplete(
113143

114144
// generate needed runtime files
115145
const entrypoint = /* javascript */ `
116-
import { AsyncLocalStorage } from 'node:async_hooks'
117146
import { createRequire } from 'node:module'
118-
import { runNextHandler } from './${RUNTIME_DIR}/dist/adapter/run/pages-and-app-handler.js'
119147
120-
globalThis.AsyncLocalStorage = AsyncLocalStorage
148+
import { runHandler } from './${RUNTIME_DIR}/dist/adapter/run/pages-and-app-handler.js'
121149
122-
const RouterServerContextSymbol = Symbol.for(
123-
'@next/router-server-methods'
124-
);
150+
const pickedOutputs = ${JSON.stringify({ isrGroups, endpoints }, null, 2)}
125151
126-
if (!globalThis[RouterServerContextSymbol]) {
127-
globalThis[RouterServerContextSymbol] = {};
128-
}
129-
130-
globalThis[RouterServerContextSymbol]['.'] = {
131-
revalidate: (...args) => {
132-
console.log('revalidate called with args:', ...args);
133-
}
134-
}
135-
136152
const require = createRequire(import.meta.url)
137153
138-
const pathnameToEntry = ${JSON.stringify(pathnameToEntry, null, 2)}
139-
140154
export default async function handler(request, context) {
141-
const url = new URL(request.url)
142-
143-
const entry = pathnameToEntry[url.pathname]
144-
if (!entry) {
145-
return new Response('Not Found', { status: 404 })
146-
}
147-
148-
const nextHandler = await require('./' + entry)
149-
150-
if (typeof nextHandler.handler !== 'function') {
151-
console.log('.handler is not a function', { nextHandler })
152-
}
153-
154-
return runNextHandler(request, context, nextHandler.handler)
155+
const response = await runHandler(request, context, pickedOutputs, require)
156+
console.log('Serving response with status:', response.status)
157+
return response
155158
}
156159
157160
export const config = ${JSON.stringify(functionConfig, null, 2)}
@@ -161,7 +164,7 @@ export async function onBuildComplete(
161164
entrypoint,
162165
)
163166

164-
netlifyAdapterContext.preparedOutputs.endpoints.push(...functionConfig.path)
167+
// netlifyAdapterContext.preparedOutputs.endpoints.push(...functionConfig.path)
165168
}
166169

167170
const copyRuntime = async (handlerDirectory: string): Promise<void> => {

src/adapter/build/routing.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -647,8 +647,6 @@ export async function onBuildComplete(
647647

648648
await copyRuntime(ROUTING_FUNCTION_DIR)
649649

650-
// TODO(pieh): middleware case would need to be split in 2 functions
651-
652650
const entrypoint = /* javascript */ `
653651
import { runNextRouting } from "./dist/adapter/run/routing.js";
654652

src/adapter/run/isr.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { getDeployStore } from '@netlify/blobs'
2+
3+
import type { ISRCacheEntry } from '../build/netlify-adapter-context.js'
4+
import type { NetlifyAdapterContext } from '../build/types.js'
5+
6+
function cacheEntryToResponse(entry: ISRCacheEntry, source: 'fallback' | 'blobs') {
7+
const headers = new Headers(entry.headers ?? {})
8+
9+
headers.set('x-isr-revalidate', String(entry.revalidate ?? 'undefined'))
10+
headers.set('x-isr-expiration', String(entry.expiration ?? 'undefined'))
11+
headers.set('x-isr-source', source)
12+
13+
return new Response(entry.content, {
14+
status: entry.status ?? 200,
15+
headers,
16+
})
17+
}
18+
19+
export async function getIsrResponse(
20+
request: Request,
21+
outputs: NetlifyAdapterContext['preparedOutputs'],
22+
) {
23+
const def = matchIsrDefinitionFromOutputs(request, outputs)
24+
if (!def) {
25+
return
26+
}
27+
28+
const cacheKey = generateIsrCacheKey(request, def)
29+
const store = getDeployStore({ consistency: 'strong', region: 'us-east-2' })
30+
31+
const cachedEntry = await store.get(cacheKey, { type: 'json' })
32+
if (cachedEntry) {
33+
return cacheEntryToResponse(cachedEntry as ISRCacheEntry, 'blobs')
34+
}
35+
36+
if (!def.fallback) {
37+
return
38+
}
39+
40+
return cacheEntryToResponse(def.fallback, 'fallback')
41+
}
42+
43+
export function matchIsrGroupFromOutputs(
44+
request: Request,
45+
outputs: Pick<NetlifyAdapterContext['preparedOutputs'], 'endpoints' | 'isrGroups'>,
46+
) {
47+
const { pathname } = new URL(request.url)
48+
49+
const endpoint = outputs.endpoints[pathname.toLowerCase()]
50+
if (!endpoint || endpoint.type !== 'isr') {
51+
return
52+
}
53+
54+
return outputs.isrGroups[endpoint.isrGroup]
55+
}
56+
57+
export function matchIsrDefinitionFromIsrGroup(
58+
request: Request,
59+
isrGroup: NetlifyAdapterContext['preparedOutputs']['isrGroups'][number],
60+
) {
61+
const { pathname } = new URL(request.url)
62+
63+
return isrGroup.find((def) => def.pathname === pathname)
64+
}
65+
66+
export function matchIsrDefinitionFromOutputs(
67+
request: Request,
68+
outputs: Pick<NetlifyAdapterContext['preparedOutputs'], 'endpoints' | 'isrGroups'>,
69+
) {
70+
const defs = matchIsrGroupFromOutputs(request, outputs)
71+
72+
if (!defs) {
73+
return
74+
}
75+
76+
return matchIsrDefinitionFromIsrGroup(request, defs)
77+
}
78+
79+
export function requestToIsrRequest(
80+
request: Request,
81+
def: NetlifyAdapterContext['preparedOutputs']['isrGroups'][number][number],
82+
) {
83+
const isrUrl = new URL(request.url)
84+
85+
// eslint-disable-next-line unicorn/no-useless-spread
86+
for (const queryKey of [...isrUrl.searchParams.keys()]) {
87+
if (!def.queryParams.includes(queryKey)) {
88+
isrUrl.searchParams.delete(queryKey)
89+
}
90+
}
91+
92+
// we should strip headers as well - at very least conditional ones, but probably better to just use allowed headers like so
93+
// "allowHeader": [
94+
// "host",
95+
// "x-matched-path",
96+
// "x-prerender-revalidate",
97+
// "x-prerender-revalidate-if-generated",
98+
// "x-next-revalidated-tags",
99+
// "x-next-revalidate-tag-token"
100+
// ],
101+
102+
return new Request(isrUrl, request)
103+
}
104+
105+
export function generateIsrCacheKey(
106+
request: Request,
107+
def: NetlifyAdapterContext['preparedOutputs']['isrGroups'][number][number],
108+
) {
109+
const parts = ['isr', def.pathname]
110+
111+
const url = new URL(request.url)
112+
113+
for (const queryParamName of def.queryParams) {
114+
const value = url.searchParams.get(queryParamName) ?? ''
115+
parts.push(`${queryParamName}=${value}`)
116+
}
117+
118+
return parts.join(':')
119+
}
120+
121+
export async function responseToCacheEntry(response: Response): Promise<ISRCacheEntry> {
122+
const content = await response.text()
123+
const headers: Record<string, string> = Object.fromEntries(response.headers.entries())
124+
125+
return { content, headers, status: response.status }
126+
}
127+
128+
export async function storeIsrGroupUpdate(update: Record<string, ISRCacheEntry>) {
129+
const store = getDeployStore({ consistency: 'strong', region: 'us-east-2' })
130+
131+
await Promise.all(
132+
Object.entries(update).map(async ([key, entry]) => {
133+
await store.setJSON(key, entry)
134+
}),
135+
)
136+
}

0 commit comments

Comments
 (0)