diff --git a/.eslintrc.js b/.eslintrc.js index a7cad672..74ce087b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,6 +6,7 @@ module.exports = { extends: '@netlify/eslint-config-node', rules: { 'max-statements': 'off', + 'max-lines': 'off', }, overrides: [ ...overrides, diff --git a/CHANGELOG.md b/CHANGELOG.md index 9be38be7..6158912f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [3.0.2](https://github.com/netlify/functions/compare/v3.0.1...v3.0.2) (2025-03-17) + + +### Bug Fixes + +* if the purge api call fails, include the api response body in the thrown error's message ([#571](https://github.com/netlify/functions/issues/571)) ([e01516d](https://github.com/netlify/functions/commit/e01516df909fc0e9ba9c553655cb7df8b5b27e51)) +* only purge alias by default if the purgeCache function is called within a deployed function ([#576](https://github.com/netlify/functions/issues/576)) ([964b0a2](https://github.com/netlify/functions/commit/964b0a27ee092f9cc5fd6726adb15439e3b9e74e)) + ## [3.0.1](https://github.com/netlify/functions/compare/v3.0.0...v3.0.1) (2025-03-04) diff --git a/package-lock.json b/package-lock.json index 745b081a..8ff837c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@netlify/functions", - "version": "3.0.1", + "version": "3.0.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@netlify/functions", - "version": "3.0.1", + "version": "3.0.2", "license": "MIT", "dependencies": { "@netlify/serverless-functions-api": "1.35.0" @@ -24,7 +24,7 @@ "semver": "^7.5.4", "tsd": "^0.31.0", "tsup": "^8.0.2", - "typescript": "^4.4.4", + "typescript": "^5.0.0", "vitest": "^2.1.8" }, "engines": { @@ -1804,9 +1804,9 @@ } }, "node_modules/@publint/pack": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.1.tgz", - "integrity": "sha512-TvCl79Y8v18ZhFGd5mjO1kYPovSBq3+4LVCi5Nfl1JI8fS8i8kXbgQFGwBJRXczim8GlW8c2LMBKTtExYXOy/A==", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.2.tgz", + "integrity": "sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==", "dev": true, "engines": { "node": ">=18" @@ -7553,12 +7553,12 @@ } }, "node_modules/publint": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.7.tgz", - "integrity": "sha512-UJAdT3pHmhxGHfFadlZZnTZWNyagwPplW4YJ7kM0ysDs45otRnusonRxeWYQHrdryWxAntsjCuXcUHkbUHGk7g==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.9.tgz", + "integrity": "sha512-irTwfRfYW38vomkxxoiZQtFtUOQKpz5m0p9Z60z4xpXrl1KmvSrX1OMARvnnolB5usOXeNfvLj6d/W3rwXKfBQ==", "dev": true, "dependencies": { - "@publint/pack": "^0.1.1", + "@publint/pack": "^0.1.2", "package-manager-detector": "^0.2.9", "picocolors": "^1.1.1", "sade": "^1.8.1" @@ -9180,16 +9180,16 @@ } }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/unbox-primitive": { @@ -11254,9 +11254,9 @@ } }, "@publint/pack": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.1.tgz", - "integrity": "sha512-TvCl79Y8v18ZhFGd5mjO1kYPovSBq3+4LVCi5Nfl1JI8fS8i8kXbgQFGwBJRXczim8GlW8c2LMBKTtExYXOy/A==", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.2.tgz", + "integrity": "sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==", "dev": true }, "@rollup/rollup-android-arm-eabi": { @@ -15328,12 +15328,12 @@ } }, "publint": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.7.tgz", - "integrity": "sha512-UJAdT3pHmhxGHfFadlZZnTZWNyagwPplW4YJ7kM0ysDs45otRnusonRxeWYQHrdryWxAntsjCuXcUHkbUHGk7g==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.9.tgz", + "integrity": "sha512-irTwfRfYW38vomkxxoiZQtFtUOQKpz5m0p9Z60z4xpXrl1KmvSrX1OMARvnnolB5usOXeNfvLj6d/W3rwXKfBQ==", "dev": true, "requires": { - "@publint/pack": "^0.1.1", + "@publint/pack": "^0.1.2", "package-manager-detector": "^0.2.9", "picocolors": "^1.1.1", "sade": "^1.8.1" @@ -16488,9 +16488,9 @@ "dev": true }, "typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true }, "unbox-primitive": { diff --git a/package.json b/package.json index 07d8bba2..1c2ced4f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ } } }, - "version": "3.0.1", + "version": "3.0.2", "description": "JavaScript utilities for Netlify Functions", "files": [ "dist/**/*.js", @@ -88,7 +88,7 @@ "semver": "^7.5.4", "tsd": "^0.31.0", "tsup": "^8.0.2", - "typescript": "^4.4.4", + "typescript": "^5.0.0", "vitest": "^2.1.8" }, "engines": { diff --git a/src/lib/purge_cache.test.ts b/src/lib/purge_cache.test.ts index 04871c56..279292fa 100644 --- a/src/lib/purge_cache.test.ts +++ b/src/lib/purge_cache.test.ts @@ -58,9 +58,111 @@ test('Calls the purge API endpoint and returns `undefined` if the operation was expect(mockAPI.fulfilled).toBeTruthy() }) -test('Throws if the API response does not have a successful status code', async () => { +test('Does not default the deploy_alias field to process.env.NETLIFY_BRANCH if supplied in the options', async () => { + const mockSiteID = '123456789' + const mockToken = '1q2w3e4r5t6y7u8i9o0p' + + process.env.NETLIFY_PURGE_API_TOKEN = mockToken + process.env.SITE_ID = mockSiteID + process.env.NETLIFY_BRANCH = 'main' + + const mockAPI = new MockFetch().post({ + body: (payload: string) => { + const data = JSON.parse(payload) + + expect(data.site_id).toBe(mockSiteID) + expect(data.deploy_alias).toBe('test') + }, + headers: { Authorization: `Bearer ${mockToken}` }, + method: 'post', + response: new Response(null, { status: 202 }), + url: `https://api.netlify.com/api/v1/purge`, + }) + // eslint-disable-next-line unicorn/consistent-function-scoping + const myFunction = async () => { + await purgeCache({ deployAlias: 'test' }) + } + + globalThis.fetch = mockAPI.fetcher + + const response = await invokeLambda(myFunction) + + expect(response).toBeUndefined() + expect(mockAPI.fulfilled).toBeTruthy() +}) + +test('Defaults the deploy_alias field to process.env.NETLIFY_BRANCH if not running locally', async () => { + const mockSiteID = '123456789' + const mockToken = '1q2w3e4r5t6y7u8i9o0p' + + process.env.NETLIFY_PURGE_API_TOKEN = mockToken + process.env.SITE_ID = mockSiteID + process.env.NETLIFY_BRANCH = 'main' + + const mockAPI = new MockFetch().post({ + body: (payload: string) => { + const data = JSON.parse(payload) + + expect(data.site_id).toBe(mockSiteID) + expect(data.deploy_alias).toBe(process.env.NETLIFY_BRANCH) + }, + headers: { Authorization: `Bearer ${mockToken}` }, + method: 'post', + response: new Response(null, { status: 202 }), + url: `https://api.netlify.com/api/v1/purge`, + }) + // eslint-disable-next-line unicorn/consistent-function-scoping + const myFunction = async () => { + await purgeCache() + } + + globalThis.fetch = mockAPI.fetcher + + const response = await invokeLambda(myFunction) + + expect(response).toBeUndefined() + expect(mockAPI.fulfilled).toBeTruthy() +}) + +test('Does not default the deploy_alias field to process.env.NETLIFY_BRANCH when running locally', async () => { + const mockSiteID = '123456789' + const mockToken = '1q2w3e4r5t6y7u8i9o0p' + + process.env.NETLIFY_PURGE_API_TOKEN = mockToken + process.env.SITE_ID = mockSiteID + process.env.NETLIFY_LOCAL = 'true' + process.env.NETLIFY_BRANCH = 'main' + + const mockAPI = new MockFetch().post({ + body: (payload: string) => { + const data = JSON.parse(payload) + + expect(data.site_id).toBe(mockSiteID) + expect(data.deploy_alias).toBeUndefined() + }, + headers: { Authorization: `Bearer ${mockToken}` }, + method: 'post', + response: new Response(null, { status: 202 }), + url: `https://api.netlify.com/api/v1/purge`, + }) + // eslint-disable-next-line unicorn/consistent-function-scoping + const myFunction = async () => { + await purgeCache() + } + + globalThis.fetch = mockAPI.fetcher + + const response = await invokeLambda(myFunction) + + expect(response).toBeUndefined() + expect(mockAPI.fulfilled).toBeTruthy() +}) + +test('Throws an error if the API response does not have a successful status code, using the response body as part of the error message', async () => { if (!hasFetchAPI) { console.warn('Skipping test requires the fetch API') + + return } const mockSiteID = '123456789' @@ -77,7 +179,7 @@ test('Throws if the API response does not have a successful status code', async }, headers: { Authorization: `Bearer ${mockToken}` }, method: 'post', - response: new Response(null, { status: 500 }), + response: new Response('site not found', { status: 404 }), url: `https://api.netlify.com/api/v1/purge`, }) // eslint-disable-next-line unicorn/consistent-function-scoping @@ -90,14 +192,54 @@ test('Throws if the API response does not have a successful status code', async try { await invokeLambda(myFunction) - throw new Error('Invocation should have failed') + expect.fail('Invocation should have failed') } catch (error) { expect((error as NodeJS.ErrnoException).message).toBe( - 'Cache purge API call returned an unexpected status code: 500', + 'Cache purge API call was unsuccessful.\nStatus: 404\nBody: site not found', ) } }) +test('Throws if the API response does not have a successful status code, does not include the response body if it is not text', async () => { + if (!hasFetchAPI) { + console.warn('Skipping test requires the fetch API') + + return + } + + const mockSiteID = '123456789' + const mockToken = '1q2w3e4r5t6y7u8i9o0p' + + process.env.NETLIFY_PURGE_API_TOKEN = mockToken + process.env.SITE_ID = mockSiteID + + const mockAPI = new MockFetch().post({ + body: (payload: string) => { + const data = JSON.parse(payload) + + expect(data.site_id).toBe(mockSiteID) + }, + headers: { Authorization: `Bearer ${mockToken}` }, + method: 'post', + response: new Response(null, { status: 500 }), + url: `https://api.netlify.com/api/v1/purge`, + }) + // eslint-disable-next-line unicorn/consistent-function-scoping + const myFunction = async () => { + await purgeCache() + } + + globalThis.fetch = mockAPI.fetcher + + try { + await invokeLambda(myFunction) + + throw new Error('Invocation should have failed') + } catch (error) { + expect((error as NodeJS.ErrnoException).message).toBe('Cache purge API call was unsuccessful.\nStatus: 500') + } +}) + test('Ignores purgeCache if in local dev with no token or site', async () => { if (!hasFetchAPI) { console.warn('Skipping test requires the fetch API') diff --git a/src/lib/purge_cache.ts b/src/lib/purge_cache.ts index f88c905a..b2f1b6aa 100644 --- a/src/lib/purge_cache.ts +++ b/src/lib/purge_cache.ts @@ -38,10 +38,24 @@ export const purgeCache = async (options: PurgeCacheOptions = {}) => { ) } + const { siteID } = options as PurgeCacheOptionsWithSiteID + const { siteSlug } = options as PurgeCacheOptionsWithSiteSlug + const { domain } = options as PurgeCacheOptionsWithDomain + + if ((siteID && siteSlug) || (siteID && domain) || (siteSlug && domain)) { + throw new Error('Can only pass one of either "siteID", "siteSlug", or "domain"') + } + const payload: PurgeAPIPayload = { cache_tags: options.tags, - deploy_alias: options.deployAlias, } + + if ('deployAlias' in options) { + payload.deploy_alias = options.deployAlias + } else if (!env.NETLIFY_LOCAL) { + payload.deploy_alias = env.NETLIFY_BRANCH + } + const token = env.NETLIFY_PURGE_API_TOKEN || options.token if (env.NETLIFY_LOCAL && !token) { @@ -50,22 +64,20 @@ export const purgeCache = async (options: PurgeCacheOptions = {}) => { return } - if ('siteSlug' in options) { - payload.site_slug = options.siteSlug - } else if ('domain' in options) { - payload.domain = options.domain + if (siteSlug) { + payload.site_slug = siteSlug + } else if (domain) { + payload.domain = domain } else { // The `siteID` from `options` takes precedence over the one from the // environment. - const siteID = options.siteID || env.SITE_ID + payload.site_id = siteID || env.SITE_ID - if (!siteID) { + if (!payload.site_id) { throw new Error( 'The Netlify site ID was not found in the execution environment. Please supply it manually using the `siteID` property.', ) } - - payload.site_id = siteID } if (!token) { @@ -91,6 +103,13 @@ export const purgeCache = async (options: PurgeCacheOptions = {}) => { }) if (!response.ok) { - throw new Error(`Cache purge API call returned an unexpected status code: ${response.status}`) + let text + try { + text = await response.text() + } catch {} + if (text) { + throw new Error(`Cache purge API call was unsuccessful.\nStatus: ${response.status}\nBody: ${text}`) + } + throw new Error(`Cache purge API call was unsuccessful.\nStatus: ${response.status}`) } }