Skip to content

feat: refresh hooks api implementation #1950

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 48 commits into from
Mar 22, 2023
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
55d6022
feat: subclass NextServer to override request handler
orinokai Feb 23, 2023
c3d5607
feat: add revalidate API route
orinokai Feb 23, 2023
3030689
feat: subclass NextServer to override request handler
orinokai Feb 23, 2023
699ad3a
feat: add revalidate API route
orinokai Feb 23, 2023
8033d60
Merge branch 'rs/refresh-hooks-poc' of github.com:netlify/netlify-plu…
orinokai Mar 1, 2023
3c805d4
feat: add refresh hooks api implementation
orinokai Mar 1, 2023
796e458
chore: increase revalidate ttl for testing
orinokai Mar 1, 2023
bf3f8d9
chore: add time to page for testing
orinokai Mar 1, 2023
2a2da7e
feat: add error reporting for caught exceptions
orinokai Mar 1, 2023
cae0a91
fix: use req.url verbatim
orinokai Mar 1, 2023
b04cb8e
chore: test throw in revalidate handler
orinokai Mar 1, 2023
de649e9
chore: fix lint issue
orinokai Mar 1, 2023
98407ea
fix: update revalidate error handling
orinokai Mar 1, 2023
f691362
fix: error handling in revalidate api function
orinokai Mar 1, 2023
443beca
chore: refactor revalidate handling for easier testing
orinokai Mar 6, 2023
e043e9d
test: add tests for refresh hooks
orinokai Mar 6, 2023
95f7c94
test: add e2e test for refresh hooks revalidate
orinokai Mar 6, 2023
238596e
feat: revalidate data routes
orinokai Mar 6, 2023
cf0fc72
fix: export named NetlifyNextServer for module interop
orinokai Mar 6, 2023
0f7d1db
fix: revalidate error messaging in demo
orinokai Mar 6, 2023
8cc7abe
fix: remove revalidate data routes for milestone 1
orinokai Mar 6, 2023
fef98a1
fix: amend test for revalidate success/failure
orinokai Mar 6, 2023
bf97d73
feat: add additional paths for routes
orinokai Mar 10, 2023
fef1c59
chore: refactor tests
orinokai Mar 10, 2023
99ed48d
chore: add tests for handlerUtils
orinokai Mar 10, 2023
3ec181f
chore: remove eslint directive
orinokai Mar 10, 2023
6b4a2ce
test: remove rsc trailing slash
orinokai Mar 10, 2023
021f825
Revert "test: remove rsc trailing slash"
orinokai Mar 10, 2023
a40925f
feat: source and transform data routes from prerenderManifest
orinokai Mar 14, 2023
6aa81df
feat: update revalidate page with more test scenarios
orinokai Mar 14, 2023
8f60f43
fix: revalidate api endpoint path order
orinokai Mar 14, 2023
9fc4123
chore: update revalidate test paths
orinokai Mar 17, 2023
0e61a26
fix: cache and normalize manifest routes
orinokai Mar 20, 2023
a8ea2bd
chore: update tests for caching and localizing routes
orinokai Mar 20, 2023
36acbdb
chore: fix eslint complaint
orinokai Mar 20, 2023
d53a11d
chore: update cypress tests
orinokai Mar 21, 2023
4e8daa5
fix: consolidated static route matching for revalidate with i18n
orinokai Mar 21, 2023
8bcdb1c
chore: improve comments on revalidate api route
orinokai Mar 21, 2023
ce62c92
chore: update snapshots and cypress tests
orinokai Mar 21, 2023
3da9e46
Merge branch 'main' into rs/refresh-hooks-api
orinokai Mar 21, 2023
228ed12
Merge branch 'main' into rs/refresh-hooks-api
orinokai Mar 21, 2023
f85d2f5
chore: reset with-revalidate TTL after testing
orinokai Mar 22, 2023
a197783
chore: add comment about paths to revalidate endpoint
orinokai Mar 22, 2023
5da076d
Merge branch 'main' into rs/refresh-hooks-api
orinokai Mar 22, 2023
d029976
chore: update cypress test to match old ttl for revalidate api
orinokai Mar 22, 2023
6ef27a5
Merge branch 'rs/refresh-hooks-api' of github.com:netlify/netlify-plu…
orinokai Mar 22, 2023
1445c26
chore: move tests to resolve edge clash
orinokai Mar 22, 2023
fd32f38
chore: add comments to functions generation
orinokai Mar 22, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cypress/integration/default/dynamic-routes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ describe('Static Routing', () => {
expect(res.body).to.contain('Dancing with the Stars')
})
})
it('revalidates page via refresh hooks on a static route', () => {
cy.request({ url: '/api/revalidate/' }).then((res) => {
expect(res.status).to.eq(200)
expect(res.body).to.equal({ revalidated: true })
})
})
})

describe('Dynamic Routing', () => {
Expand Down
10 changes: 10 additions & 0 deletions demos/default/pages/api/revalidate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default async function handler(req, res) {
try {
const path = '/getStaticProps/with-revalidate/'
await res.revalidate(path)
console.log('Revalidated:', path)
return res.json({ code: 200, message: 'success' })
} catch (err) {
return res.status(500).send({ code: 500, message: err.message })
}
}
7 changes: 4 additions & 3 deletions demos/default/pages/getStaticProps/with-revalidate.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import Link from 'next/link'

const Show = ({ show }) => (
const Show = ({ show, time }) => (
<div>
<p>This page uses getStaticProps() to pre-fetch a TV show.</p>
<p>This page uses getStaticProps() to pre-fetch a TV show at {time}</p>

<hr />

Expand All @@ -22,9 +22,10 @@ export async function getStaticProps(context) {
return {
props: {
show: data,
time: new Date().toISOString(),
},
// ODB handler will use the minimum TTL=60s
revalidate: 1,
revalidate: 300,
}
}

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"testMatch": [
"**/test/**/*.spec.js",
"**/test/**/*.spec.ts",
"**/*.test.ts",
"!**/test/e2e/**",
"!**/test/fixtures/**",
"!**/test/sample/**",
Expand Down
8 changes: 8 additions & 0 deletions packages/runtime/src/helpers/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ export const generateFunctions = async (
await ensureDir(join(functionsDir, functionName))
await writeFile(join(functionsDir, functionName, `${functionName}.js`), apiHandlerSource)
await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js'))
await copyFile(
join(__dirname, '..', '..', 'lib', 'templates', 'server.js'),
join(functionsDir, functionName, 'server.js'),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we get some comments on why we're copying this file? (This whole functions.ts file could use those tbh hah, but I won't put that on you)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, i totally agree! i don't know enough about some of the functions in there to comment it all, but i've added comments to generateFunctions() in fd32f38

)
await copyFile(
join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'),
join(functionsDir, functionName, 'handlerUtils.js'),
Expand All @@ -67,6 +71,10 @@ export const generateFunctions = async (
await ensureDir(join(functionsDir, functionName))
await writeFile(join(functionsDir, functionName, `${functionName}.js`), handlerSource)
await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js'))
await copyFile(
join(__dirname, '..', '..', 'lib', 'templates', 'server.js'),
join(functionsDir, functionName, 'server.js'),
)
await copyFile(
join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'),
join(functionsDir, functionName, 'handlerUtils.js'),
Expand Down
14 changes: 6 additions & 8 deletions packages/runtime/src/templates/getHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ import { outdent as javascript } from 'outdent'

import type { NextConfig } from '../helpers/config'

import type { NextServerType } from './handlerUtils'

/* eslint-disable @typescript-eslint/no-var-requires */

const { promises } = require('fs')
const { Server } = require('http')
const path = require('path')
Expand All @@ -22,9 +19,9 @@ const {
getMaxAge,
getMultiValueHeaders,
getPrefetchResponse,
getNextServer,
normalizePath,
} = require('./handlerUtils')
const { NetlifyNextServer } = require('./server')
/* eslint-enable @typescript-eslint/no-var-requires */

type Mutable<T> = {
Expand Down Expand Up @@ -68,21 +65,21 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str
// We memoize this because it can be shared between requests, but don't instantiate it until
// the first request because we need the host and port.
let bridge: NodeBridge
const getBridge = (event: HandlerEvent): NodeBridge => {
const getBridge = (event: HandlerEvent, context: HandlerContext): NodeBridge => {
if (bridge) {
return bridge
}
const url = new URL(event.rawUrl)
const port = Number.parseInt(url.port) || 80
base = url.origin

const NextServer: NextServerType = getNextServer()
const nextServer = new NextServer({
const nextServer = new NetlifyNextServer({
conf,
dir,
customServer: false,
hostname: url.hostname,
port,
netlifyRevalidateToken: context.clientContext?.custom?.odb_refresh_hooks,
})
const requestHandler = nextServer.getRequestHandler()
const server = new Server(async (req, res) => {
Expand Down Expand Up @@ -119,7 +116,7 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str
process.env._NETLIFY_GRAPH_TOKEN = graphToken
}

const { headers, ...result } = await getBridge(event).launcher(event, context)
const { headers, ...result } = await getBridge(event, context).launcher(event, context)

// Convert all headers to multiValueHeaders

Expand Down Expand Up @@ -180,6 +177,7 @@ export const getHandler = ({ isODB = false, publishDir = '../../../.next', appDi
// We copy the file here rather than requiring from the node module
const { Bridge } = require("./bridge");
const { augmentFsModule, getMaxAge, getMultiValueHeaders, getPrefetchResponse, getNextServer, normalizePath } = require('./handlerUtils')
const { NetlifyNextServer } = require('./server')

${isODB ? `const { builder } = require("@netlify/functions")` : ''}
const { config } = require("${publishDir}/required-server-files.json")
Expand Down
44 changes: 44 additions & 0 deletions packages/runtime/src/templates/handlerUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs, { createWriteStream, existsSync } from 'fs'
import { ServerResponse } from 'http'
import { tmpdir } from 'os'
import path from 'path'
import { pipeline } from 'stream'
Expand Down Expand Up @@ -222,3 +223,46 @@ export const normalizePath = (event: HandlerEvent) => {
// Ensure that paths are encoded - but don't double-encode them
return new URL(event.rawUrl).pathname
}

export const netlifyApiFetch = <T>({
endpoint,
payload,
token,
method = 'GET',
}: {
endpoint: string
payload: unknown
token: string
method: 'GET' | 'POST'
}): Promise<T> =>
new Promise((resolve, reject) => {
const body = JSON.stringify(payload)

const req = https.request(
{
hostname: 'api.netlify.com',
port: 443,
path: `/api/v1/${endpoint}`,
method,
headers: {
'Content-Type': 'application/json',
'Content-Length': body.length,
Authorization: `Bearer ${token}`,
},
},
(res: ServerResponse) => {
let data = ''
res.on('data', (chunk) => {
data += chunk
})
res.on('end', () => {
resolve(JSON.parse(data))
})
},
)

req.on('error', reject)

req.write(body)
req.end()
})
63 changes: 63 additions & 0 deletions packages/runtime/src/templates/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { mockRequest } from 'next/dist/server/lib/mock-request'

import { netlifyApiFetch, getNextServer, NextServerType } from './handlerUtils'
import { NetlifyNextServer } from './server'

const NextServer: NextServerType = getNextServer()

jest.mock('./handlerUtils', () => {
const originalModule = jest.requireActual('./handlerUtils')

return {
__esModule: true,
...originalModule,
netlifyApiFetch: jest.fn(({ payload }) => {
switch (payload.paths[0]) {
case '/getStaticProps/with-revalidate/':
return Promise.resolve({ code: 200, message: 'Revalidated' })
case '/not-a-path/':
return Promise.resolve({ code: 404, message: '404' })
default:
return Promise.reject(new Error('Error'))
}
}),
}
})

jest.spyOn(NextServer.prototype, 'getRequestHandler').mockImplementation(() => () => Promise.resolve())

Object.setPrototypeOf(NetlifyNextServer, jest.fn())

const nextServer = new NetlifyNextServer({ conf: {}, dev: false })
const requestHandler = nextServer.getRequestHandler()

describe('the netlify next server', () => {
it('intercepts a request containing an x-prerender-revalidate header', async () => {
const { req: mockReq, res: mockRes } = mockRequest(
'/getStaticProps/with-revalidate/',
{ 'x-prerender-revalidate': 'test' },
'GET',
)
await requestHandler(mockReq, mockRes)
expect(netlifyApiFetch).toHaveBeenCalled()
})

it('silently revalidates and returns the original handler response', async () => {
const { req: mockReq, res: mockRes } = mockRequest(
'/getStaticProps/with-revalidate/',
{ 'x-prerender-revalidate': 'test' },
'GET',
)
await expect(requestHandler(mockReq, mockRes)).resolves.toBe(undefined)
})

it('throws an error when the revalidate API returns a 404 response', async () => {
const { req: mockReq, res: mockRes } = mockRequest('/not-a-path/', { 'x-prerender-revalidate': 'test' }, 'GET')
await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Unsuccessful revalidate - 404')
})

it('throws an error when the revalidate API is unreachable', async () => {
const { req: mockReq, res: mockRes } = mockRequest('', { 'x-prerender-revalidate': 'test' }, 'GET')
await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Unsuccessful revalidate - Error')
})
})
62 changes: 62 additions & 0 deletions packages/runtime/src/templates/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { NodeRequestHandler, Options } from 'next/dist/server/next-server'

// import { netlifyRoutesForNextRoute } from '../helpers/utils'

import { netlifyApiFetch, getNextServer, NextServerType } from './handlerUtils'

const NextServer: NextServerType = getNextServer()

interface NetlifyNextServerOptions extends Options {
netlifyRevalidateToken?: string
}

class NetlifyNextServer extends NextServer {
private netlifyRevalidateToken?: string

public constructor(options: NetlifyNextServerOptions) {
super(options)
this.netlifyRevalidateToken = options.netlifyRevalidateToken
}

public getRequestHandler(): NodeRequestHandler {
const handler = super.getRequestHandler()
return async (req, res, parsedUrl) => {
// on-demand revalidation request
if (req.headers['x-prerender-revalidate']) {
await this.netlifyRevalidate(req.url)
}
return handler(req, res, parsedUrl)
}
}

private async netlifyRevalidate(url: string) {
try {
// call netlify API to revalidate the path, including its data routes
const result = await netlifyApiFetch<{ ok: boolean; code: number; message: string }>({
endpoint: `sites/${process.env.SITE_ID}/refresh_on_demand_builders`,
payload: {
paths: [
url,
// ...netlifyRoutesForNextRoute({
// route: url,
// buildId: this.buildId,
// i18n: this.nextConfig.i18n,
// }),
// url.endsWith('/') ? `${url.slice(0, -1)}.rsc/` : `${url}.rsc`,
],
domain: this.hostname,
},
token: this.netlifyRevalidateToken,
method: 'POST',
})
if (result.ok !== true) {
throw new Error(result.message)
}
} catch (error) {
console.log('Error revalidating', error.message)
throw error
}
}
}

export { NetlifyNextServer }