From 4f254823994dc8b6b0cd7c2bb711fd3614bcb065 Mon Sep 17 00:00:00 2001 From: moritzraho Date: Thu, 21 Aug 2025 14:58:13 +0200 Subject: [PATCH 1/2] feat: invalidate cache --- lib/StorageError.js | 7 +-- lib/invalidate-cache.js | 33 ++++++++++++++ package.json | 2 + src/deploy-web.js | 17 ++++++- test/lib/invalidate-cache.test.js | 75 ++++++++++++++++++++++++++++++ test/src/deploy-web.test.js | 76 +++++++++++++++++++++++++++---- 6 files changed, 196 insertions(+), 14 deletions(-) create mode 100644 lib/invalidate-cache.js create mode 100644 test/lib/invalidate-cache.test.js diff --git a/lib/StorageError.js b/lib/StorageError.js index 3d5bb5c..23ea1e4 100644 --- a/lib/StorageError.js +++ b/lib/StorageError.js @@ -21,14 +21,11 @@ const Updater = createUpdater( messages ) -const E = ErrorWrapper( - 'WebStorageError', - 'WebLib', - Updater -) +const E = ErrorWrapper('WebStorageError', 'WebLib', Updater) E('ERROR_INVALID_HEADER_NAME', '`%s` is not a valid response header name') E('ERROR_INVALID_HEADER_VALUE', '`%s` is not a valid response header value for `%s`') +E('ERROR_CACHE_INVALIDATION', 'Failed to invalidate cache: %s') function logAndThrow (e) { logger.error(JSON.stringify(e, null, 2)) diff --git a/lib/invalidate-cache.js b/lib/invalidate-cache.js new file mode 100644 index 0000000..332e187 --- /dev/null +++ b/lib/invalidate-cache.js @@ -0,0 +1,33 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +const { createFetch } = require('@adobe/aio-lib-core-networking') +const { codes } = require('./StorageError') + +module.exports = async function invalidateCache (deployApiHost, namespace, tokenHeader) { + const fetch = createFetch() + + const url = `https://${deployApiHost}/cdn-api/namespaces/${namespace}/cache` + try { + const response = await fetch(url, { + method: 'DELETE', + headers: { + Authorization: tokenHeader + } + }) + if (!response.ok) { + throw new Error(`${url} ${response.status} ${response.statusText} ${await response.text()}`) + } + return response.json() + } catch (error) { + throw new codes.ERROR_CACHE_INVALIDATION({ messageValues: [`${url} ${error.message}`], sdkDetails: {} }) + } +} diff --git a/package.json b/package.json index baa5944..94ac1e0 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,9 @@ "dependencies": { "@adobe/aio-lib-core-config": "^5", "@adobe/aio-lib-core-logging": "^3", + "@adobe/aio-lib-core-networking": "^5.0.4", "@adobe/aio-lib-core-tvm": "^4", + "@adobe/aio-lib-env": "^3.0.1", "@aws-sdk/client-s3": "^3.624.0", "core-js": "^3.25.1", "fs-extra": "^11", diff --git a/src/deploy-web.js b/src/deploy-web.js index f140d55..9ce81e6 100644 --- a/src/deploy-web.js +++ b/src/deploy-web.js @@ -1,5 +1,5 @@ /* -Copyright 2020 Adobe. All rights reserved. +Copyright 2025 Adobe. All rights reserved. This file is licensed to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -12,6 +12,7 @@ governing permissions and limitations under the License. const RemoteStorage = require('../lib/remote-storage') const getS3Credentials = require('../lib/getS3Creds') +const invalidateCache = require('../lib/invalidate-cache') const fs = require('fs-extra') const path = require('path') @@ -21,6 +22,10 @@ const deployWeb = async (config, log) => { throw new Error('cannot deploy web, app has no frontend or config is invalid') } + if (!config.web.namespace || !config.web.apihost || !config.web.auth_handler) { + throw new Error('cannot deploy web, config is missing "web.namespace", "web.apihost", or "web.auth_handler" fields') + } + /// build files const dist = config.web.distProd if (!fs.existsSync(dist) || @@ -30,6 +35,16 @@ const deployWeb = async (config, log) => { throw new Error(`missing files in ${dist}, maybe you forgot to build your UI ?`) } + /// deploy + // 1. invalidate cache + + // this will trigger a login + const authHeader = await config.web.auth_handler() + const namespace = config.web.namespace + const apihost = config.web.apihost // this is the deploy service apihost + await invalidateCache(apihost, namespace, authHeader) + + // 2. upload files const creds = await getS3Credentials(config) const remoteStorage = new RemoteStorage(creds) diff --git a/test/lib/invalidate-cache.test.js b/test/lib/invalidate-cache.test.js new file mode 100644 index 0000000..4d67a69 --- /dev/null +++ b/test/lib/invalidate-cache.test.js @@ -0,0 +1,75 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +jest.mock('@adobe/aio-lib-core-networking', () => ({ + createFetch: jest.fn() +})) + +const { createFetch } = require('@adobe/aio-lib-core-networking') +const invalidateCache = require('../../lib/invalidate-cache') + +describe('invalidate-cache', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('succeeds when API returns ok', async () => { + const mockJson = { status: 'ok' } + const mockFetch = jest.fn(async () => ({ ok: true, json: async () => mockJson })) + createFetch.mockReturnValue(mockFetch) + + const host = 'deploy.example.com' + const ns = 'my-ns' + const header = 'Bearer token' + + await expect(invalidateCache(host, ns, header)).resolves.toEqual(mockJson) + expect(mockFetch).toHaveBeenCalledWith(`https://${host}/cdn-api/namespaces/${ns}/cache`, expect.objectContaining({ + method: 'DELETE', + headers: { Authorization: header } + })) + }) + + test('throws wrapped error when response not ok', async () => { + const mockFetch = jest.fn(async () => ({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: async () => 'boom' + })) + createFetch.mockReturnValue(mockFetch) + + const host = 'deploy.example.com' + const ns = 'my-ns' + const header = 'Bearer token' + + await expect(() => invalidateCache(host, ns, header)).toThrowWithMessageContaining([ + '[WebLib:ERROR_CACHE_INVALIDATION]', + 'failed to invalidate cache', + '500' + ]) + }) + + test('throws wrapped error when network fails', async () => { + const mockFetch = jest.fn(async () => { throw new Error('network down') }) + createFetch.mockReturnValue(mockFetch) + + const host = 'deploy.example.com' + const ns = 'my-ns' + const header = 'Bearer token' + + await expect(() => invalidateCache(host, ns, header)).toThrowWithMessageContaining([ + '[WebLib:ERROR_CACHE_INVALIDATION]', + 'failed to invalidate cache', + 'network down' + ]) + }) +}) diff --git a/test/src/deploy-web.test.js b/test/src/deploy-web.test.js index a909adc..53cc290 100644 --- a/test/src/deploy-web.test.js +++ b/test/src/deploy-web.test.js @@ -12,6 +12,8 @@ governing permissions and limitations under the License. const { vol } = global.mockFs() const deployWeb = require('../../src/deploy-web') +jest.mock('../../lib/invalidate-cache') +const invalidateCache = require('../../lib/invalidate-cache') const fs = require('fs-extra') jest.mock('fs-extra') @@ -38,6 +40,7 @@ describe('deploy-web', () => { mockRemoteStorageInstance.folderExists.mockReset() mockRemoteStorageInstance.uploadDir.mockReset() getS3Credentials.mockClear() + invalidateCache.mockReset() global.cleanFs(vol) }) @@ -48,6 +51,13 @@ describe('deploy-web', () => { await expect(deployWeb({ app: { hasFrontEnd: false } })).rejects.toThrow('cannot deploy web') }) + test('throws if web config missing required fields', async () => { + const base = { app: { hasFrontend: true } } + await expect(deployWeb({ ...base, web: {} })).rejects.toThrow('config is missing "web.namespace", "web.apihost", or "web.auth_handler"') + await expect(deployWeb({ ...base, web: { namespace: 'ns' } })).rejects.toThrow('config is missing "web.namespace", "web.apihost", or "web.auth_handler"') + await expect(deployWeb({ ...base, web: { namespace: 'ns', apihost: 'deploy.example.com' } })).rejects.toThrow('config is missing "web.namespace", "web.apihost", or "web.auth_handler"') + }) + test('throws if src dir does not exist', async () => { const config = { s3: { @@ -57,7 +67,10 @@ describe('deploy-web', () => { hasFrontend: true }, web: { - distProd: 'dist' + distProd: 'dist', + auth_handler: jest.fn(async () => 'Bearer token'), + namespace: 'ns', + apihost: 'deploy.example.com' } } await expect(deployWeb(config)).rejects.toThrow('missing files in dist') @@ -72,7 +85,10 @@ describe('deploy-web', () => { hasFrontend: true }, web: { - distProd: 'dist' + distProd: 'dist', + auth_handler: jest.fn(async () => 'Bearer token'), + namespace: 'ns', + apihost: 'deploy.example.com' } } fs.existsSync.mockReturnValue(true) @@ -89,7 +105,10 @@ describe('deploy-web', () => { hasFrontend: true }, web: { - distProd: 'dist' + distProd: 'dist', + auth_handler: jest.fn(async () => 'Bearer token'), + namespace: 'ns', + apihost: 'deploy.example.com' } } fs.existsSync.mockReturnValue(true) @@ -114,13 +133,17 @@ describe('deploy-web', () => { hostname: 'host' }, web: { - distProd: 'dist' + distProd: 'dist', + auth_handler: jest.fn(async () => 'Bearer token'), + namespace: 'ns', + apihost: 'deploy.example.com' } } fs.existsSync.mockReturnValue(true) fs.lstatSync.mockReturnValue({ isDirectory: () => true }) fs.readdirSync.mockReturnValue({ length: 1 }) await expect(deployWeb(config)).resolves.toEqual('https://ns.host/index.html') + expect(invalidateCache).toHaveBeenCalledWith('deploy.example.com', 'ns', 'Bearer token') expect(getS3Credentials).toHaveBeenCalledWith(config) expect(RemoteStorage).toHaveBeenCalledWith('fakecreds') expect(mockRemoteStorageInstance.uploadDir).toHaveBeenCalledWith('dist', 'somefolder', config, null) @@ -144,7 +167,10 @@ describe('deploy-web', () => { hostname: 'host' }, web: { - distProd: 'dist' + distProd: 'dist', + auth_handler: jest.fn(async () => 'Bearer token'), + namespace: 'ns', + apihost: 'deploy.example.com' } } fs.existsSync.mockReturnValue(true) @@ -154,6 +180,7 @@ describe('deploy-web', () => { // for func coverage mockRemoteStorageInstance.uploadDir.mockImplementation((a, b, c, func) => func('somefile')) await expect(deployWeb(config, mockLogger)).resolves.toEqual('https://ns.host/index.html') + expect(invalidateCache).toHaveBeenCalledWith('deploy.example.com', 'ns', 'Bearer token') expect(getS3Credentials).toHaveBeenCalledWith(config) expect(RemoteStorage).toHaveBeenCalledWith('fakecreds') expect(mockRemoteStorageInstance.uploadDir).toHaveBeenCalledWith('dist', 'somefolder', config, expect.any(Function)) @@ -176,7 +203,10 @@ describe('deploy-web', () => { hostname: 'host' }, web: { - distProd: 'dist' + distProd: 'dist', + auth_handler: jest.fn(async () => 'Bearer token'), + namespace: 'ns', + apihost: 'deploy.example.com' } } fs.existsSync.mockReturnValue(true) @@ -187,6 +217,7 @@ describe('deploy-web', () => { mockRemoteStorageInstance.folderExists.mockResolvedValue(true) await expect(deployWeb(config, mockLogger)).resolves.toEqual('https://ns.host/index.html') + expect(invalidateCache).toHaveBeenCalledWith('deploy.example.com', 'ns', 'Bearer token') expect(getS3Credentials).toHaveBeenCalledWith(config) expect(mockLogger).toHaveBeenCalledWith('warning: an existing deployment will be overwritten') expect(RemoteStorage).toHaveBeenCalledWith('fakecreds') @@ -210,7 +241,10 @@ describe('deploy-web', () => { hostname: 'host' }, web: { - distProd: 'dist' + distProd: 'dist', + auth_handler: jest.fn(async () => 'Bearer token'), + namespace: 'ns', + apihost: 'deploy.example.com' } } fs.existsSync.mockReturnValue(true) @@ -220,6 +254,7 @@ describe('deploy-web', () => { mockRemoteStorageInstance.folderExists.mockResolvedValue(true) await expect(deployWeb(config)).resolves.toEqual('https://ns.host/index.html') + expect(invalidateCache).toHaveBeenCalledWith('deploy.example.com', 'ns', 'Bearer token') expect(getS3Credentials).toHaveBeenCalledWith(config) expect(mockRemoteStorageInstance.folderExists).toHaveBeenCalledWith('somefolder/') expect(mockRemoteStorageInstance.uploadDir).toHaveBeenCalledWith('dist', 'somefolder', config, null) @@ -227,6 +262,28 @@ describe('deploy-web', () => { expect(mockRemoteStorageInstance.emptyFolder).toHaveBeenCalledWith('somefolder/') }) + test('invalidates cache', async () => { + const config = { + ow: { namespace: 'ns', auth: 'password' }, + s3: { folder: 'somefolder' }, + app: { hasFrontend: true, hostname: 'host' }, + web: { + distProd: 'dist', + auth_handler: jest.fn(async () => 'Bearer token'), + namespace: 'ns', + apihost: 'deploy.example.com' + } + } + fs.existsSync.mockReturnValue(true) + fs.lstatSync.mockReturnValue({ isDirectory: () => true }) + fs.readdirSync.mockReturnValue({ length: 1 }) + mockRemoteStorageInstance.folderExists.mockResolvedValue(false) + invalidateCache.mockResolvedValue({ status: 'ok' }) + + await expect(deployWeb(config)).resolves.toEqual('https://ns.host/index.html') + expect(invalidateCache).toHaveBeenCalledWith('deploy.example.com', 'ns', 'Bearer token') + }) + test('calls to s3 should use ending slash', async () => { const config = { ow: { @@ -243,7 +300,10 @@ describe('deploy-web', () => { hostname: 'host' }, web: { - distProd: 'dist' + distProd: 'dist', + auth_handler: jest.fn(async () => 'Bearer token'), + namespace: 'ns', + apihost: 'deploy.example.com' } } const emptyFolder = jest.fn() From 79405e291ba1b7f6c2c0cc8066b50b71c6242bad Mon Sep 17 00:00:00 2001 From: moritzraho Date: Thu, 21 Aug 2025 15:09:55 +0200 Subject: [PATCH 2/2] fix thrown error --- lib/invalidate-cache.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/invalidate-cache.js b/lib/invalidate-cache.js index 332e187..f33eb05 100644 --- a/lib/invalidate-cache.js +++ b/lib/invalidate-cache.js @@ -10,7 +10,7 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ const { createFetch } = require('@adobe/aio-lib-core-networking') -const { codes } = require('./StorageError') +const { codes, logAndThrow } = require('./StorageError') module.exports = async function invalidateCache (deployApiHost, namespace, tokenHeader) { const fetch = createFetch() @@ -24,10 +24,10 @@ module.exports = async function invalidateCache (deployApiHost, namespace, token } }) if (!response.ok) { - throw new Error(`${url} ${response.status} ${response.statusText} ${await response.text()}`) + logAndThrow(new codes.ERROR_CACHE_INVALIDATION({ messageValues: [`${url} ${response.status} ${response.statusText} ${await response.text()}`], sdkDetails: {} })) } return response.json() } catch (error) { - throw new codes.ERROR_CACHE_INVALIDATION({ messageValues: [`${url} ${error.message}`], sdkDetails: {} }) + logAndThrow(new codes.ERROR_CACHE_INVALIDATION({ messageValues: [`${url} ${error.message}`], sdkDetails: {} })) } }