From 8ac4da379f08fb054ff246aefb563ed6c81683f2 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 7 Apr 2026 16:45:49 +0300 Subject: [PATCH 1/4] chore: add cset --- .changeset/purple-roses-throw.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/purple-roses-throw.md diff --git a/.changeset/purple-roses-throw.md b/.changeset/purple-roses-throw.md new file mode 100644 index 00000000000..93d0f15ea5f --- /dev/null +++ b/.changeset/purple-roses-throw.md @@ -0,0 +1,6 @@ +--- +'@sap-ux/preview-middleware': patch +'@sap-ux/adp-tooling': patch +--- + +feat: Serve merged manifest.json via proxy in CF ADP preview mode From 5f609de53edfe827c3619b4fadc1a6a5164c7e1c Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Tue, 7 Apr 2026 16:48:51 +0300 Subject: [PATCH 2/4] feat: read manifest after building and serve it upon request --- .../adp-tooling/src/preview/adp-preview.ts | 24 +++- .../test/unit/preview/adp-preview.test.ts | 110 +++++++++++++++++- packages/preview-middleware/src/base/flp.ts | 6 +- 3 files changed, 131 insertions(+), 9 deletions(-) diff --git a/packages/adp-tooling/src/preview/adp-preview.ts b/packages/adp-tooling/src/preview/adp-preview.ts index bbaa6b7f9a5..43975df5435 100644 --- a/packages/adp-tooling/src/preview/adp-preview.ts +++ b/packages/adp-tooling/src/preview/adp-preview.ts @@ -37,7 +37,8 @@ import { isV4DescriptorChange } from './change-handler'; import { addCustomFragment } from './descriptor-change-handler'; -import { getExistingAdpProjectType } from '../base/helper'; +import { getExistingAdpProjectType, readManifestFromBuildPath } from '../base/helper'; +import { runBuild } from '../base/project-builder'; import path from 'node:path'; declare global { // false positive, const can't be used here https://github.com/eslint/eslint/issues/15896 @@ -179,6 +180,16 @@ export class AdpPreview { this.descriptorVariantId = descriptorVariant.id; this.projectTypeValue = undefined; this.routesHandler = new RoutesHandler(this.project, this.util, {} as AbapServiceProvider, this.logger); + + const config = this.config as { cfBuildPath: string }; + const manifest = readManifestFromBuildPath(config.cfBuildPath) as MergedAppDescriptor['manifest']; + this.mergedDescriptor = { + name: descriptorVariant.id, + url: '/', + manifest, + asyncHints: { libs: [], components: [] } + }; + return descriptorVariant.layer; } @@ -187,10 +198,14 @@ export class AdpPreview { * The descriptor is refreshed only if the global flag is set to true. */ async sync(): Promise { - if ('cfBuildPath' in this.config) { + if (!global.__SAP_UX_MANIFEST_SYNC_REQUIRED__ && this.mergedDescriptor) { return; } - if (!global.__SAP_UX_MANIFEST_SYNC_REQUIRED__ && this.mergedDescriptor) { + if ('cfBuildPath' in this.config) { + await runBuild(this.util.getProject().getRootPath(), { ADP_BUILDER_MODE: 'preview' }); + this.mergedDescriptor.manifest = readManifestFromBuildPath( + this.config.cfBuildPath + ) as MergedAppDescriptor['manifest']; return; } if (!this.lrep || !this.descriptorVariantId) { @@ -221,6 +236,9 @@ export class AdpPreview { res.send(JSON.stringify(this.descriptor.manifest, undefined, 2)); } else if (req.path === '/Component-preload.js') { res.status(404).send(); + } else if ('cfBuildPath' in this.config) { + // CF mode: let serveStatic handle remaining requests + next(); } else { // check if the requested file exists in the file system (replace .js with .* for typescript) const files = await this.project.byGlob(req.path.replace('.js', '.*')); diff --git a/packages/adp-tooling/test/unit/preview/adp-preview.test.ts b/packages/adp-tooling/test/unit/preview/adp-preview.test.ts index 784810b4e64..7634b09d7af 100644 --- a/packages/adp-tooling/test/unit/preview/adp-preview.test.ts +++ b/packages/adp-tooling/test/unit/preview/adp-preview.test.ts @@ -13,6 +13,7 @@ import * as systemAccess from '@sap-ux/system-access/dist/base/connect'; import * as serviceWriter from '@sap-ux/odata-service-writer/dist/data/annotations'; import * as helper from '../../../src/base/helper'; +import * as projectBuilder from '../../../src/base/project-builder'; import * as editors from '../../../src/writer/editors'; import { AdpPreview } from '../../../src'; import * as manifestService from '../../../src/base/abap/manifest-service'; @@ -242,6 +243,9 @@ describe('AdaptationProject', () => { }); test('should initialize with cfBuildPath mode', async () => { + const mockCfManifest = { 'sap.app': { id: 'cf.test.app' } }; + jest.spyOn(helper, 'readManifestFromBuildPath').mockReturnValue(mockCfManifest as any); + const adp = new AdpPreview( { target: { @@ -262,6 +266,10 @@ describe('AdaptationProject', () => { expect(adp['descriptorVariantId']).toBe(parsedVariant.id); expect(adp['routesHandler']).toBeDefined(); expect(adp['provider']).toBeUndefined(); + expect(adp.descriptor).toBeDefined(); + expect(adp.descriptor.manifest).toEqual(mockCfManifest); + expect(adp.descriptor.url).toBe('/'); + expect(adp.descriptor.name).toBe(parsedVariant.id); }); }); @@ -295,8 +303,11 @@ describe('AdaptationProject', () => { global.__SAP_UX_MANIFEST_SYNC_REQUIRED__ = false; }); - test('should return early when cfBuildPath is set', async () => { - // Create a separate nock scope for this test to avoid interfering with other tests + test('should return early when cfBuildPath is set and no sync required', async () => { + const mockCfManifest = { 'sap.app': { id: 'cf.test.app' } }; + jest.spyOn(helper, 'readManifestFromBuildPath').mockReturnValue(mockCfManifest as any); + const runBuildSpy = jest.spyOn(projectBuilder, 'runBuild').mockResolvedValue(); + const testBackend = 'https://test-backend.example'; const adp = new AdpPreview( { @@ -313,9 +324,46 @@ describe('AdaptationProject', () => { const parsedVariant = JSON.parse(descriptorVariant); await adp.init(parsedVariant); - // sync should return immediately without making any backend calls - // Since cfBuildPath is set, sync should return early + // sync should return early because mergedDescriptor is already set and no sync required + await adp.sync(); + expect(runBuildSpy).not.toHaveBeenCalled(); + }); + + test('should rebuild and re-read manifest when sync required in cfBuildPath mode', async () => { + const initialManifest = { 'sap.app': { id: 'cf.test.app' } }; + const updatedManifest = { 'sap.app': { id: 'cf.test.app.updated' } }; + const readManifestSpy = jest + .spyOn(helper, 'readManifestFromBuildPath') + .mockReturnValueOnce(initialManifest as any) + .mockReturnValueOnce(updatedManifest as any); + const runBuildSpy = jest.spyOn(projectBuilder, 'runBuild').mockResolvedValue(); + + const testBackend = '/test-backend'; + const adp = new AdpPreview( + { + target: { + url: testBackend + }, + cfBuildPath: 'dist' + }, + mockProject as unknown as ReaderCollection, + middlewareUtil, + logger + ); + + const parsedVariant = JSON.parse(descriptorVariant); + await adp.init(parsedVariant); + expect(adp.descriptor.manifest).toEqual(initialManifest); + + // Trigger sync + global.__SAP_UX_MANIFEST_SYNC_REQUIRED__ = true; await adp.sync(); + + expect(runBuildSpy).toHaveBeenCalledWith('/projects/adp.project', { + ADP_BUILDER_MODE: 'preview' + }); + expect(readManifestSpy).toHaveBeenLastCalledWith('dist'); + expect(adp.descriptor.manifest).toEqual(updatedManifest); }); test('updates merged descriptor', async () => { @@ -508,6 +556,56 @@ describe('AdaptationProject', () => { }); }); + describe('proxy - cfBuildPath mode', () => { + let server: supertest.Agent; + const next = jest.fn().mockImplementation((_req, res) => res.status(200).send()); + + beforeAll(async () => { + const mockCfManifest = { 'sap.app': { id: 'cf.proxy.test' } }; + jest.spyOn(helper, 'readManifestFromBuildPath').mockReturnValue(mockCfManifest as any); + jest.spyOn(projectBuilder, 'runBuild').mockResolvedValue(); + + const adp = new AdpPreview( + { + target: { + url: backend + }, + cfBuildPath: 'dist' + }, + mockProject as unknown as ReaderCollection, + middlewareUtil, + logger + ); + + await adp.init(JSON.parse(descriptorVariant)); + + const app = express(); + app.use(adp.descriptor.url, adp.proxy.bind(adp)); + app.use(next); + + server = supertest(app); + }); + + afterEach(() => { + global.__SAP_UX_MANIFEST_SYNC_REQUIRED__ = false; + }); + + test('/manifest.json serves merged manifest', async () => { + const response = await server.get('/manifest.json').expect(200); + expect(JSON.parse(response.text)).toEqual({ 'sap.app': { id: 'cf.proxy.test' } }); + }); + + test('/Component-preload.js returns 404', async () => { + await server.get('/Component-preload.js').expect(404); + }); + + test('other requests call next() instead of redirecting', async () => { + next.mockClear(); + await server.get('/some-other-file.js').expect(200); + expect(next).toHaveBeenCalled(); + }); + }); + describe('onChangeRequest', () => { const mockFs = {} as unknown as Editor; const mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as Logger; @@ -982,6 +1080,10 @@ describe('AdaptationProject', () => { describe('addApis - cfBuildPath mode', () => { let cfBuildPathServer: supertest.Agent; beforeAll(async () => { + jest.spyOn(helper, 'readManifestFromBuildPath').mockReturnValue({ + 'sap.app': { id: 'cf.api.test' } + } as any); + const adp = new AdpPreview( { target: { diff --git a/packages/preview-middleware/src/base/flp.ts b/packages/preview-middleware/src/base/flp.ts index 703171c18bd..900e095887f 100644 --- a/packages/preview-middleware/src/base/flp.ts +++ b/packages/preview-middleware/src/base/flp.ts @@ -1223,7 +1223,7 @@ export class FlpSandbox { // CF ADP build path mode: serve built resources directly from build output if ('cfBuildPath' in config) { - const manifest = this.setupCfBuildMode(config.cfBuildPath); + const manifest = this.setupCfBuildMode(config.cfBuildPath, adp); configureRta(this.rta, layer, variant.id, false, true); await this.init(manifest, variant.reference); await this.setupAdpCommonHandlers(adp); @@ -1255,10 +1255,12 @@ export class FlpSandbox { * Setup the CF build path mode for the ADP project. * * @param cfBuildPath path to the build output folder + * @param adp AdpPreview instance for proxying manifest requests * @returns the manifest */ - private setupCfBuildMode(cfBuildPath: string): Manifest { + private setupCfBuildMode(cfBuildPath: string, adp: AdpPreview): Manifest { const manifest = readManifestFromBuildPath(cfBuildPath); + this.router.use(adp.descriptor.url, adp.proxy.bind(adp)); this.router.use('/', serveStatic(cfBuildPath)); this.logger.info(`Initialized CF ADP with cfBuildPath, serving from ${cfBuildPath}`); return manifest; From 9a36df0da77b87f01f63bee85184ab018ac800b2 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Wed, 8 Apr 2026 09:03:20 +0300 Subject: [PATCH 3/4] refator: improve adp class --- .../adp-tooling/src/preview/adp-preview.ts | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/adp-tooling/src/preview/adp-preview.ts b/packages/adp-tooling/src/preview/adp-preview.ts index 43975df5435..82b69270a0b 100644 --- a/packages/adp-tooling/src/preview/adp-preview.ts +++ b/packages/adp-tooling/src/preview/adp-preview.ts @@ -19,10 +19,12 @@ import RoutesHandler from './routes-handler'; import OvpRoutesHandler from './ovp-routes-handler'; import type { AdpPreviewConfig, + AdpPreviewConfigWithTarget, CommonChangeProperties, DescriptorVariant, OperationType, - CommonAdditionalChangeInfoProperties + CommonAdditionalChangeInfoProperties, + AdpPreviewConfigWithBuildPath } from '../types'; import type { Editor } from 'mem-fs-editor'; import { @@ -80,6 +82,10 @@ export class AdpPreview { private lrep: LayeredRepositoryService | undefined; private descriptorVariantId: string | undefined; private projectTypeValue?: AdaptationProjectType; + /** + * Flag to indicate if the preview is running in CF ADP build mode, where the manifest is read from the build output instead of being fetched from the backend. + */ + private readonly isCfBuildMode: boolean; /** * @returns merged manifest. @@ -138,7 +144,9 @@ export class AdpPreview { private readonly project: ReaderCollection, private readonly util: MiddlewareUtils, private readonly logger: ToolsLogger - ) {} + ) { + this.isCfBuildMode = 'cfBuildPath' in config; + } /** * Fetch all required configurations from the backend and initialize all configurations. @@ -147,14 +155,15 @@ export class AdpPreview { * @returns {Promise} The UI5 flex layer for which editing is enabled. */ async init(descriptorVariant: DescriptorVariant): Promise { - if ('cfBuildPath' in this.config) { + if (this.isCfBuildMode) { return this.initCfBuildMode(descriptorVariant); } + const config = this.config as AdpPreviewConfigWithTarget; this.descriptorVariantId = descriptorVariant.id; this.provider = await createAbapServiceProvider( - this.config.target, - { ignoreCertErrors: this.config.ignoreCertErrors }, + config.target, + { ignoreCertErrors: config.ignoreCertErrors }, true, this.logger ); @@ -181,7 +190,7 @@ export class AdpPreview { this.projectTypeValue = undefined; this.routesHandler = new RoutesHandler(this.project, this.util, {} as AbapServiceProvider, this.logger); - const config = this.config as { cfBuildPath: string }; + const config = this.config as AdpPreviewConfigWithBuildPath; const manifest = readManifestFromBuildPath(config.cfBuildPath) as MergedAppDescriptor['manifest']; this.mergedDescriptor = { name: descriptorVariant.id, @@ -201,11 +210,10 @@ export class AdpPreview { if (!global.__SAP_UX_MANIFEST_SYNC_REQUIRED__ && this.mergedDescriptor) { return; } - if ('cfBuildPath' in this.config) { + if (this.isCfBuildMode) { await runBuild(this.util.getProject().getRootPath(), { ADP_BUILDER_MODE: 'preview' }); - this.mergedDescriptor.manifest = readManifestFromBuildPath( - this.config.cfBuildPath - ) as MergedAppDescriptor['manifest']; + const buildPath = (this.config as AdpPreviewConfigWithBuildPath).cfBuildPath; + this.mergedDescriptor.manifest = readManifestFromBuildPath(buildPath) as MergedAppDescriptor['manifest']; return; } if (!this.lrep || !this.descriptorVariantId) { @@ -236,7 +244,7 @@ export class AdpPreview { res.send(JSON.stringify(this.descriptor.manifest, undefined, 2)); } else if (req.path === '/Component-preload.js') { res.status(404).send(); - } else if ('cfBuildPath' in this.config) { + } else if (this.isCfBuildMode) { // CF mode: let serveStatic handle remaining requests next(); } else { From 3130d7d12b316f66525b770476c5e32414e58c92 Mon Sep 17 00:00:00 2001 From: Nikita Baranov Date: Wed, 8 Apr 2026 09:55:31 +0300 Subject: [PATCH 4/4] refactor: add cf proxy for manifest --- .../adp-tooling/src/preview/adp-preview.ts | 20 ++++++++++++++++--- .../test/unit/preview/adp-preview.test.ts | 10 +++------- packages/preview-middleware/src/base/flp.ts | 2 +- .../test/unit/base/flp.test.ts | 1 + 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/adp-tooling/src/preview/adp-preview.ts b/packages/adp-tooling/src/preview/adp-preview.ts index 82b69270a0b..31dba7a8d1c 100644 --- a/packages/adp-tooling/src/preview/adp-preview.ts +++ b/packages/adp-tooling/src/preview/adp-preview.ts @@ -244,9 +244,6 @@ export class AdpPreview { res.send(JSON.stringify(this.descriptor.manifest, undefined, 2)); } else if (req.path === '/Component-preload.js') { res.status(404).send(); - } else if (this.isCfBuildMode) { - // CF mode: let serveStatic handle remaining requests - next(); } else { // check if the requested file exists in the file system (replace .js with .* for typescript) const files = await this.project.byGlob(req.path.replace('.js', '.*')); @@ -259,6 +256,23 @@ export class AdpPreview { } } + /** + * CF build mode proxy that intercepts manifest.json requests and delegates everything else to the next middleware. + * + * @param req incoming request + * @param res outgoing response object + * @param next next middleware that is to be called if the request cannot be handled + */ + async cfProxy(req: Request, res: Response, next: NextFunction): Promise { + if (req.path === '/manifest.json') { + await this.sync(); + res.status(200); + res.send(JSON.stringify(this.descriptor.manifest, undefined, 2)); + } else { + next(); + } + } + /** * Add additional APIs to the router that are required for adaptation projects only. * diff --git a/packages/adp-tooling/test/unit/preview/adp-preview.test.ts b/packages/adp-tooling/test/unit/preview/adp-preview.test.ts index 7634b09d7af..d72ee470284 100644 --- a/packages/adp-tooling/test/unit/preview/adp-preview.test.ts +++ b/packages/adp-tooling/test/unit/preview/adp-preview.test.ts @@ -556,7 +556,7 @@ describe('AdaptationProject', () => { }); }); - describe('proxy - cfBuildPath mode', () => { + describe('cfProxy', () => { let server: supertest.Agent; const next = jest.fn().mockImplementation((_req, res) => res.status(200).send()); @@ -580,7 +580,7 @@ describe('AdaptationProject', () => { await adp.init(JSON.parse(descriptorVariant)); const app = express(); - app.use(adp.descriptor.url, adp.proxy.bind(adp)); + app.use(adp.descriptor.url, adp.cfProxy.bind(adp)); app.use(next); server = supertest(app); @@ -595,11 +595,7 @@ describe('AdaptationProject', () => { expect(JSON.parse(response.text)).toEqual({ 'sap.app': { id: 'cf.proxy.test' } }); }); - test('/Component-preload.js returns 404', async () => { - await server.get('/Component-preload.js').expect(404); - }); - - test('other requests call next() instead of redirecting', async () => { + test('other requests call next()', async () => { next.mockClear(); await server.get('/some-other-file.js').expect(200); expect(next).toHaveBeenCalled(); diff --git a/packages/preview-middleware/src/base/flp.ts b/packages/preview-middleware/src/base/flp.ts index 900e095887f..46ebd7e3e23 100644 --- a/packages/preview-middleware/src/base/flp.ts +++ b/packages/preview-middleware/src/base/flp.ts @@ -1260,7 +1260,7 @@ export class FlpSandbox { */ private setupCfBuildMode(cfBuildPath: string, adp: AdpPreview): Manifest { const manifest = readManifestFromBuildPath(cfBuildPath); - this.router.use(adp.descriptor.url, adp.proxy.bind(adp)); + this.router.use(adp.descriptor.url, adp.cfProxy.bind(adp)); this.router.use('/', serveStatic(cfBuildPath)); this.logger.info(`Initialized CF ADP with cfBuildPath, serving from ${cfBuildPath}`); return manifest; diff --git a/packages/preview-middleware/test/unit/base/flp.test.ts b/packages/preview-middleware/test/unit/base/flp.test.ts index 24a78e7cf8d..5eefd6307ae 100644 --- a/packages/preview-middleware/test/unit/base/flp.test.ts +++ b/packages/preview-middleware/test/unit/base/flp.test.ts @@ -1959,6 +1959,7 @@ describe('initAdp', () => { }, resources: [], proxy: jest.fn(), + cfProxy: jest.fn(), sync: syncSpy, onChangeRequest: jest.fn(), addApis: jest.fn(),