Skip to content
9 changes: 7 additions & 2 deletions packages/config/src/api/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ import { NetlifyAPI } from 'netlify'
import { removeUndefined } from '../utils/remove_falsy.js'

// Retrieve Netlify API client, if an access token was passed
export const getApiClient = function ({ token, offline, testOpts = {}, host, scheme, pathPrefix }) {
export const getApiClient = function ({ cache, token, offline, testOpts = {}, host, scheme, pathPrefix }) {
if (!token || offline) {
return
}

// TODO: find less intrusive way to mock HTTP requests
const parameters = removeUndefined({ scheme: testOpts.scheme || scheme, host: testOpts.host || host, pathPrefix })
const parameters = removeUndefined({
cache,
scheme: testOpts.scheme || scheme,
host: testOpts.host || host,
pathPrefix,
})
const api = new NetlifyAPI(token, parameters)
return api
}
31 changes: 15 additions & 16 deletions packages/config/src/api/site_info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const getSiteInfo = async function ({
const integrations =
mode === 'buildbot' && !offline
? await getIntegrations({
api,
siteId,
testOpts,
offline,
Expand All @@ -74,10 +75,9 @@ export const getSiteInfo = async function ({
return { siteInfo, accounts: [], addons: [], integrations }
}

const [siteInfo, accounts, addons, integrations] = await Promise.all([
const [siteInfo, accounts, integrations] = await Promise.all([
getSite(api, siteId, siteFeatureFlagPrefix),
getAccounts(api),
getAddons(api, siteId),
getIntegrations({ siteId, testOpts, offline, accountId, token, featureFlags, extensionApiBaseUrl, mode }),
])

Expand All @@ -87,7 +87,7 @@ export const getSiteInfo = async function ({
siteInfo.build_settings.env = envelope
}

return { siteInfo, accounts, addons, integrations }
return { siteInfo, accounts, addons: [], integrations }
}

const getSite = async function (api: NetlifyAPI, siteId: string, siteFeatureFlagPrefix: string) {
Expand Down Expand Up @@ -128,20 +128,8 @@ const getAccounts = async function (api: NetlifyAPI): Promise<MinimalAccount[]>
}
}

const getAddons = async function (api: NetlifyAPI, siteId: string) {
if (siteId === undefined) {
return []
}

try {
const addons = await (api as any).listServiceInstancesForSite({ siteId })
return Array.isArray(addons) ? addons : []
} catch (error) {
throwUserError(`Failed retrieving addons for site ${siteId}: ${error.message}. ${ERROR_CALL_TO_ACTION}`)
}
}

type GetIntegrationsOpts = {
api?: NetlifyAPI
siteId?: string
accountId?: string
testOpts: TestOptions
Expand All @@ -153,6 +141,7 @@ type GetIntegrationsOpts = {
}

const getIntegrations = async function ({
api,
siteId,
accountId,
testOpts,
Expand Down Expand Up @@ -196,6 +185,16 @@ const getIntegrations = async function ({
: `${baseUrl}site/${siteId}/integrations/safe`

try {
// Even though integrations don't come through the Netlify API, we can
// still leverage the API cache if one is being used.
if (api?.cache) {
const response = await api.cache.get(url, 'get', {})

if (response !== null && Array.isArray(response.body)) {
return response.body
}
}

const requestOptions = {} as RequestInit

// This is used to identify where the request is coming from
Expand Down
3 changes: 2 additions & 1 deletion packages/config/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export type Config = {
*/
export const resolveConfig = async function (opts): Promise<Config> {
const {
apiCache,
cachedConfig,
cachedConfigPath,
host,
Expand All @@ -63,7 +64,7 @@ export const resolveConfig = async function (opts): Promise<Config> {
...optsA
} = addDefaultOpts(opts) as $TSFixMe
// `api` is not JSON-serializable, so we cannot cache it inside `cachedConfig`
const api = getApiClient({ token, offline, host, scheme, pathPrefix, testOpts })
const api = getApiClient({ token, offline, host, scheme, pathPrefix, testOpts, cache: apiCache })

const parsedCachedConfig = await getCachedConfig({ cachedConfig, cachedConfigPath, token, api })
// If there is a cached config, use it. The exception is when a default config,
Expand Down
33 changes: 33 additions & 0 deletions packages/js-client/src/buffered_response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { getHeadersObject, HeadersObject } from './headers.js'
import { getResponseType } from './methods/response_type.js'

type JSONResponse = { type: 'json'; body: any }
type TextResponse = { type: 'text'; body: string }

/**
* An HTTP response that has been fully read. The body has been buffered and so
* it can be read multiple times and serialized to disk.
*/
export type BufferedResponse = { headers: HeadersObject; timestamp: number } & (JSONResponse | TextResponse)

/**
* Consumes an HTTP response and returns a `BufferedResponse` object.
*/
export const getBufferedResponse = async (res: Response): Promise<BufferedResponse> => {
const headers = getHeadersObject(res.headers)
const data = {
headers,
timestamp: Date.now(),
}
const type = getResponseType(res)

if (type === 'json') {
return {
...data,
type: 'json',
body: await res.json(),
}
}

return { ...data, type: 'text', body: await res.text() }
}
198 changes: 198 additions & 0 deletions packages/js-client/src/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { promises as fs } from 'fs'
import path from 'path'

import test, { ExecutionContext } from 'ava'

import { APICache } from '../lib/index.js'

const dateNow = Date.now
const globalFetch = globalThis.fetch

test.afterEach(() => {
Date.now = dateNow
globalThis.fetch = globalFetch
})

const getMockFetch = (t: ExecutionContext, mocks: Record<string, () => Response>) => {
const calls: Record<string, number> = {}

const mockFetch = async (input: URL | RequestInfo) => {
for (const url in mocks) {
if (input.toString() === url) {
calls[url] = calls[url] ?? 0
calls[url]++

return mocks[url]()
}
}

t.fail(`Unexpected fetch call: ${input}`)

return new Response(null, { status: 400 })
}

return { calls, mockFetch }
}

const sleep = (ms: number) =>
new Promise((resolve) => {
setTimeout(resolve, ms)
})

test.serial('Returns response from cache if within TTL', async (t) => {
const mockEndpoint = 'https://api.netlify/endpoint'
const mockResponse = { messages: ['Hello', 'Goodbye'] }
const { calls, mockFetch } = getMockFetch(t, {
[mockEndpoint]: () => Response.json(mockResponse),
})

globalThis.fetch = mockFetch

const cache = new APICache({
ttl: 30,
swr: 30,
})

const res1 = await cache.get(mockEndpoint, 'get', {})
t.deepEqual(res1?.body, mockResponse)
t.deepEqual(res1?.headers, { 'content-type': 'application/json' })

const now = Date.now()

const future = now + 10
Date.now = () => future

const res2 = await cache.get(mockEndpoint, 'get', {})
t.deepEqual(res2?.body, mockResponse)
t.deepEqual(res2?.headers, { 'content-type': 'application/json' })

t.is(calls[mockEndpoint], 1)
})

test.serial('Returns response from cache if outside of TTL but within SWR', async (t) => {
const mockEndpoint = 'https://api.netlify/endpoint'
const mockResponse = { messages: ['Hello', 'Goodbye'] }
const { calls, mockFetch } = getMockFetch(t, {
[mockEndpoint]: () => Response.json(mockResponse),
})

globalThis.fetch = mockFetch

const cache = new APICache({
ttl: 30,
swr: 60,
})

const res1 = await cache.get(mockEndpoint, 'get', {})
t.deepEqual(res1?.body, mockResponse)
t.deepEqual(res1?.headers, { 'content-type': 'application/json' })

const now = Date.now()

const future = now + 45
Date.now = () => future

const res2 = await cache.get(mockEndpoint, 'get', {})
t.deepEqual(res2?.body, mockResponse)
t.deepEqual(res2?.headers, { 'content-type': 'application/json' })

t.is(calls[mockEndpoint], 1)

await sleep(10)

t.is(calls[mockEndpoint], 2)

const cacheKey = cache.getCacheKey(mockEndpoint, 'get', {})
t.is(cache.entries[cacheKey].timestamp, future)
})

test.serial('Returns fresh response if outside of TTL and SWR', async (t) => {
const mockEndpoint = 'https://api.netlify/endpoint'
const mockResponse = { messages: ['Hello', 'Goodbye'] }
const { calls, mockFetch } = getMockFetch(t, {
[mockEndpoint]: () => Response.json(mockResponse),
})

globalThis.fetch = mockFetch

const cache = new APICache({
ttl: 30,
swr: 60,
})

const res1 = await cache.get(mockEndpoint, 'get', {})
t.deepEqual(res1?.body, mockResponse)
t.deepEqual(res1?.headers, { 'content-type': 'application/json' })

const now = Date.now()

const future = now + 90
Date.now = () => future

const res2 = await cache.get(mockEndpoint, 'get', {})
t.deepEqual(res2?.body, mockResponse)
t.deepEqual(res2?.headers, { 'content-type': 'application/json' })

t.is(calls[mockEndpoint], 2)
})

test.serial('Uses disk fallback', async (t) => {
const mockEndpoint = 'https://api.netlify/endpoint'
const mockResponse = { messages: ['Hello', 'Goodbye'] }
const { calls, mockFetch } = getMockFetch(t, {
[mockEndpoint]: () => Response.json(mockResponse),
})

globalThis.fetch = mockFetch

const fsPath = await fs.mkdtemp('netlify-js-client-test')
const cache = new APICache({
fsPath,
ttl: 30,
swr: 60,
})

t.teardown(async () => {
await fs.rm(fsPath, { recursive: true })
})

const now = Date.now()
const cacheKey = cache.getCacheKey(mockEndpoint, 'get', {})
const filePath = path.join(fsPath, cacheKey)
const file = {
body: mockResponse,
headers: {
'content-type': 'application/json',
},
timestamp: now - 20,
type: 'json',
}

await fs.writeFile(filePath, JSON.stringify(file))

const res1 = await cache.get(mockEndpoint, 'get', {})
t.deepEqual(res1?.body, mockResponse)
t.deepEqual(res1?.headers, { 'content-type': 'application/json' })

t.falsy(calls[mockEndpoint])

const future = now + 20
Date.now = () => future

const res2 = await cache.get(mockEndpoint, 'get', {})
t.deepEqual(res2?.body, mockResponse)
t.deepEqual(res2?.headers, { 'content-type': 'application/json' })

t.falsy(calls[mockEndpoint])

await sleep(10)

t.is(calls[mockEndpoint], 1)

const newFile = await fs.readFile(filePath, 'utf8')
const data = JSON.parse(newFile)

t.deepEqual(data.body, mockResponse)
t.deepEqual(data.headers, { 'content-type': 'application/json' })
t.is(data.timestamp, future)
})
Loading
Loading