From fd66dcd15c926172bff16a367ff25cf44f962345 Mon Sep 17 00:00:00 2001 From: "david.bendavid" Date: Mon, 13 Apr 2026 14:52:05 +0300 Subject: [PATCH 1/4] Add instructions for creating packages for NZ (central institution) --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index c5a0389d..6d75478e 100644 --- a/README.md +++ b/README.md @@ -506,6 +506,24 @@ To ensure smooth development, debugging, and code management, we recommend setti - The ZIP file, e.g., `DEMO_INST-Auto1.zip`, is automatically created in the `dist/` directory after a successful build. +### Creating Packages for Central Institutions (NZ) + +To create a customization package that supports both individual institutions (IZ) and central institutions (NZ), follow these steps: + +1. For **Central Institution (NZ)** packages, add the following property to the `build-settings.env` file: + ``` + ADDON_NAME=CustomModuleCentral + ``` + +2. Ensure the `INST_ID` and `VIEW_ID` are set appropriately for the central institution. + +3. Run the build command as usual: + ```bash + npm run build + ``` + +This configuration allows the package to be deployed for central institutions while maintaining compatibility with individual institution customizations. + ### Step 6: Upload Customization Package to Alma 1. In Alma, navigate to **Discovery > View List > Edit**. 2. Go to the **Manage Customization Package** tab. From 13a49891c941ea5113ee57fc3540b680b838bb33 Mon Sep 17 00:00:00 2001 From: DavidbdExl Date: Tue, 14 Apr 2026 10:05:09 +0300 Subject: [PATCH 2/4] Add note that NZ development is only available in July version --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 6d75478e..ad13315a 100644 --- a/README.md +++ b/README.md @@ -522,6 +522,8 @@ To create a customization package that supports both individual institutions (IZ npm run build ``` +**Note:** The development that makes a difference for NZ (central institutions) is only available in the July version and later. + This configuration allows the package to be deployed for central institutions while maintaining compatibility with individual institution customizations. ### Step 6: Upload Customization Package to Alma From 7a0ebcc27c080a104149488178aea841346c05db Mon Sep 17 00:00:00 2001 From: "david.bendavid" Date: Tue, 4 Aug 2026 16:26:02 +0300 Subject: [PATCH 3/4] URM-335163: Fix asset manifest for proxy --- postbuild.js | 122 ++++++++++++++++++++++--- proxy/proxy-utils.mjs | 168 ++++++++++++++++++++++++++++++++++- proxy/proxy.conf.mjs | 77 +++++++++++++++- proxy/proxy.const.mjs | 9 +- tests/asset-manifest.test.js | 85 ++++++++++++++++++ tests/proxy.test.js | 21 +++++ 6 files changed, 464 insertions(+), 18 deletions(-) create mode 100644 tests/asset-manifest.test.js create mode 100644 tests/proxy.test.js diff --git a/postbuild.js b/postbuild.js index 24d9e183..c5937666 100644 --- a/postbuild.js +++ b/postbuild.js @@ -11,10 +11,92 @@ function removeDirectory(directory, callback) { fs.rm(directory, { recursive: true, force: true }, callback); } +function normalizeManifestPath(filePath, rootDirectory) { + const absoluteRootDirectory = path.resolve(rootDirectory || path.dirname(filePath)); + const absoluteFilePath = path.resolve(filePath); + const relativePath = path.relative(absoluteRootDirectory, absoluteFilePath); + if (!relativePath || relativePath === '.' || relativePath.startsWith('..')) { + return ''; + } + + return relativePath.split(path.sep).join('/'); +} + +function collectAssetManifest(rootDirectory) { + if (!fs.existsSync(rootDirectory)) { + throw new Error(`Asset manifest generation failed: output directory does not exist: ${rootDirectory}`); + } + + if (!fs.statSync(rootDirectory).isDirectory()) { + throw new Error(`Asset manifest generation failed: output path is not a directory: ${rootDirectory}`); + } + + const files = []; + const directories = new Set(); + const stack = [rootDirectory]; + + while (stack.length > 0) { + const currentDirectory = stack.pop(); + const entries = fs.readdirSync(currentDirectory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + const fullPath = path.join(currentDirectory, entry.name); + const relativePath = normalizeManifestPath(fullPath, rootDirectory); + + if (!relativePath) { + continue; + } + + if (entry.isDirectory()) { + directories.add(relativePath); + stack.push(fullPath); + } else if (entry.isFile()) { + if (entry.name === 'asset-manifest.json') { + continue; + } + files.push(relativePath); + } + } + } + + const parentDirectories = new Set(); + for (const filePath of files) { + const parts = filePath.split('/').slice(0, -1); + let currentPath = ''; + + for (const part of parts) { + currentPath = currentPath ? `${currentPath}/${part}` : part; + parentDirectories.add(currentPath); + } + } + + for (const parentDirectory of parentDirectories) { + directories.add(parentDirectory); + } + + return { + files: files.sort((a, b) => a.localeCompare(b)), + directories: Array.from(directories).sort((a, b) => a.localeCompare(b)), + }; +} + +function writeAssetManifest(rootDirectory) { + if (!fs.existsSync(rootDirectory)) { + throw new Error(`Asset manifest generation failed: output directory does not exist: ${rootDirectory}`); + } + + const manifest = collectAssetManifest(rootDirectory); + const manifestPath = path.join(rootDirectory, 'asset-manifest.json'); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + console.log(`[AssetManifest] Created ${manifestPath} with ${manifest.files.length} files and ${manifest.directories.length} directories`); + return manifestPath; +} + function renameAndArchive() { - fs.rename(distPath, targetPath, (err) => { - if (err) throw err; + try { + fs.renameSync(distPath, targetPath); console.log(`Renamed directory to ${targetPath}`); + writeAssetManifest(targetPath); const output = fs.createWriteStream(zipPath); const archive = archiver('zip', { zlib: { level: 9 } }); @@ -38,17 +120,35 @@ function renameAndArchive() { }); archive.pipe(output); - archive.directory(targetPath, path.basename(targetPath)); // This ensures the directory itself is included + archive.directory(targetPath, path.basename(targetPath)); archive.finalize(); - }); + } catch (error) { + console.error(error.message); + process.exit(1); + } } -// Check if target directory exists and remove it if it does -if (fs.existsSync(targetPath)) { - removeDirectory(targetPath, (err) => { - if (err) throw err; +function runPostbuild() { + if (fs.existsSync(targetPath)) { + removeDirectory(targetPath, (err) => { + if (err) { + console.error(err.message); + process.exit(1); + } + renameAndArchive(); + }); + } else { renameAndArchive(); - }); -} else { - renameAndArchive(); + } } + +if (require.main === module) { + runPostbuild(); +} + +module.exports = { + removeDirectory, + normalizeManifestPath, + collectAssetManifest, + writeAssetManifest, +}; diff --git a/proxy/proxy-utils.mjs b/proxy/proxy-utils.mjs index 519c7eec..93582870 100644 --- a/proxy/proxy-utils.mjs +++ b/proxy/proxy-utils.mjs @@ -1,14 +1,180 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import http from 'node:http'; +import https from 'node:https'; +import { PROXY_TARGET } from './proxy.const.mjs'; + // Added deepMerge utility to retain unspecified fields export function deepMerge(target, source) { if (typeof target !== 'object' || target === null) return source; if (typeof source !== 'object' || source === null) return source; - const out = Array.isArray(target) ? [...target] : {...target}; + + const out = Array.isArray(target) ? [...target] : { ...target }; + for (const [k, v] of Object.entries(source)) { + if (Array.isArray(v) && ['files', 'directories'].includes(k)) { + const existing = Array.isArray(out[k]) ? out[k] : []; + const mergedArray = [...new Set([...existing, ...v])]; + out[k] = mergedArray; + continue; + } + if (v && typeof v === 'object' && !Array.isArray(v) && typeof out[k] === 'object' && out[k] !== null && !Array.isArray(out[k])) { out[k] = deepMerge(out[k], v); } else { out[k] = v; } } + return out; } + +function parseBuildSettingsEnv(envFilePath = path.resolve(process.cwd(), 'build-settings.env')) { + if (!fs.existsSync(envFilePath)) { + return {}; + } + + const content = fs.readFileSync(envFilePath, 'utf8'); + const parsed = {}; + + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + const separatorIndex = trimmed.indexOf('='); + if (separatorIndex === -1) { + continue; + } + + const key = trimmed.slice(0, separatorIndex).trim(); + const value = trimmed.slice(separatorIndex + 1).trim(); + parsed[key] = value; + } + + return parsed; +} + +export function getCustomModuleManifestRoot() { + const env = parseBuildSettingsEnv(); + if (env.INST_ID && env.VIEW_ID) { + return path.resolve(process.cwd(), 'dist', `${env.INST_ID}-${env.VIEW_ID}`); + } + + return path.resolve(process.cwd(), 'dist', 'custom-module'); +} + +export function isCustomModuleAssetManifestRequest(requestPath) { + const normalizedPath = (requestPath || '').split('?')[0].replace(/^\/+/, '/'); + const match = normalizedPath.match(/^\/(?:nde\/)?custom\/([^/]+)\/asset-manifest\.json$/); + + if (!match) { + return false; + } + + return !match[1].endsWith('-CENTRAL_PACKAGE'); +} + +export function shouldProxyLandingPageRequest(requestPath) { + const normalizedPath = (requestPath || '').split('?')[0].replace(/^\/+/, '/'); + return normalizedPath === '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/nde/home' || normalizedPath === '/home'; +} + +export function shouldProxyLandingPageAssetRequest(requestPath) { + const normalizedPath = (requestPath || '').split('?')[0].replace(/^\/+/, '/'); + return /^\/(?:nde\/)?custom\/[^/]+\/assets\/landingpage(?:\/|$)/.test(normalizedPath); +} + +export function resolveCustomModuleManifestPath(requestPath, buildRoot = getCustomModuleManifestRoot()) { + if (!isCustomModuleAssetManifestRequest(requestPath)) { + return null; + } + return path.join(buildRoot, 'asset-manifest.json').split(path.sep).join('/'); +} + +export function buildMergedManifestResponse(requestPath, localManifestPath, targetBaseUrl = PROXY_TARGET) { + const normalizedRequestPath = (requestPath || '').split('?')[0]; + const targetManifestUrl = new URL(normalizedRequestPath.replace(/^\/+/, '/'), targetBaseUrl); + const targetManifestUrlString = targetManifestUrl.toString(); + const localManifestPathResolved = path.resolve(localManifestPath); + + console.log(`[manifest] requestPath=${requestPath}`); + console.log(`[manifest] targetUrl=${targetManifestUrlString}`); + console.log(`[manifest] localManifestPath=${localManifestPathResolved}`); + + return new Promise((resolve, reject) => { + const transport = targetManifestUrl.protocol === 'https:' ? https : http; + const request = transport.get(targetManifestUrlString, (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + console.log(`[manifest] target manifest response status=${response.statusCode}`); + if (response.statusCode && response.statusCode >= 200 && response.statusCode < 300 && body) { + try { + const targetManifest = JSON.parse(body); + const localManifest = fs.existsSync(localManifestPathResolved) + ? JSON.parse(fs.readFileSync(localManifestPathResolved, 'utf8')) + : { files: [], directories: [] }; + console.log('[manifest] target manifest read successfully'); + console.log('[manifest] local manifest read successfully'); + const mergedManifest = deepMerge(targetManifest, localManifest); + console.log('[manifest] merged manifest payload=', JSON.stringify(mergedManifest, null, 2)); + resolve({ + statusCode: 200, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(mergedManifest), + }); + } catch (error) { + reject(error); + } + return; + } + + console.log('[manifest] target manifest unavailable or empty, falling back to local manifest'); + if (fs.existsSync(localManifestPathResolved)) { + try { + const localManifest = JSON.parse(fs.readFileSync(localManifestPathResolved, 'utf8')); + console.log('[manifest] local manifest fallback payload=', JSON.stringify(localManifest, null, 2)); + resolve({ + statusCode: 200, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(localManifest), + }); + } catch (error) { + reject(error); + } + return; + } + + resolve({ + statusCode: 404, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ error: `Manifest not found at ${localManifestPathResolved}` }), + }); + }); + }); + + request.on('error', (error) => { + console.log(`[manifest] target manifest request failed: ${error.message}`); + if (fs.existsSync(localManifestPathResolved)) { + try { + const localManifest = JSON.parse(fs.readFileSync(localManifestPathResolved, 'utf8')); + console.log('[manifest] local manifest fallback payload=', JSON.stringify(localManifest, null, 2)); + resolve({ + statusCode: 200, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(localManifest), + }); + return; + } catch (readError) { + reject(readError); + } + return; + } + + reject(error); + }); + }); +} diff --git a/proxy/proxy.conf.mjs b/proxy/proxy.conf.mjs index 573b54de..4add047c 100644 --- a/proxy/proxy.conf.mjs +++ b/proxy/proxy.conf.mjs @@ -1,6 +1,25 @@ +import fs from 'node:fs'; import {PROXY_TARGET} from "./proxy.const.mjs"; import {customizationConfigOverride} from "./customization_config_override.mjs"; -import {deepMerge} from "./proxy-utils.mjs"; +import {buildMergedManifestResponse, deepMerge, resolveCustomModuleManifestPath, shouldProxyLandingPageAssetRequest, shouldProxyLandingPageRequest} from "./proxy-utils.mjs"; + +async function serveCustomModuleManifest(req, res) { + const manifestPath = resolveCustomModuleManifestPath(req.url); + if (!manifestPath) { + return false; + } + + try { + const response = await buildMergedManifestResponse(req.url, manifestPath, PROXY_TARGET); + res.writeHead(response.statusCode, response.headers); + res.end(response.body); + return true; + } catch (error) { + res.writeHead(500, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: `Unable to read Custom Module asset manifest from ${manifestPath}: ${error.message}` })); + return true; + } +} @@ -8,6 +27,36 @@ import {deepMerge} from "./proxy-utils.mjs"; const proxyRules = [ + { + context: ['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/nde/home', '/home'], + target: PROXY_TARGET, + secure: true, + changeOrigin: true, + logLevel: 'debug', + selfHandleResponse: true, + onProxyRes(proxyRes, req, res) { + const chunks = []; + proxyRes.on('data', chunk => chunks.push(chunk)); + proxyRes.on('end', () => { + const body = Buffer.concat(chunks); + res.statusCode = proxyRes.statusCode || 200; + res.setHeader('content-type', proxyRes.headers['content-type'] || 'text/html; charset=utf-8'); + res.end(body); + }); + } + }, + { + context: [ + '/custom/*/assets/landingpage', + '/custom/*/assets/landingpage/**', + '/nde/custom/*/assets/landingpage', + '/nde/custom/*/assets/landingpage/**' + ], + target: PROXY_TARGET, + secure: true, + changeOrigin: true, + logLevel: 'debug', + }, { context: [ '/custom/*/assets', @@ -22,6 +71,30 @@ const proxyRules = [ pathRewrite: (path) => path.replace(/^\/(?:nde\/)?custom\/[^/]+\/assets\/?/, '/assets/'), }, + { + context: ['/custom/*/asset-manifest.json', '/nde/custom/*/asset-manifest.json'], + target: PROXY_TARGET, + secure: true, + changeOrigin: true, + logLevel: 'debug', + selfHandleResponse: true, + onProxyRes(proxyRes, req, res) { + serveCustomModuleManifest(req, res).then((handled) => { + if (handled) { + return; + } + + const chunks = []; + proxyRes.on('data', chunk => chunks.push(chunk)); + proxyRes.on('end', () => { + const body = Buffer.concat(chunks); + res.statusCode = proxyRes.statusCode || 200; + res.setHeader('content-type', proxyRes.headers['content-type'] || 'application/json'); + res.end(body); + }); + }); + } + }, { context: ['/primaws/rest/pub/configuration/vid/'], target: PROXY_TARGET, @@ -65,7 +138,7 @@ const proxyRules = [ }, { context: [ - '**', '!/nde/custom/**' + '**', '!/nde/custom/**', '!/nde/home', '!/home' ], target: PROXY_TARGET, secure: true, diff --git a/proxy/proxy.const.mjs b/proxy/proxy.const.mjs index 6e00421e..a54a4ba8 100644 --- a/proxy/proxy.const.mjs +++ b/proxy/proxy.const.mjs @@ -1,7 +1,8 @@ //set the url of the server you want to test your code with and start the development server using the following command: // ng serve --proxy-config ./proxy/proxy.conf.mjs const environments = { - 'example': 'https://myPrimoVE.com', - } - - export const PROXY_TARGET = environments['example']; \ No newline at end of file + example: 'https://myPrimoVE.com', + alma: 'https://sqa03-eu01.alma.exlibrisgroup.com' +}; + +export const PROXY_TARGET = environments['alma']; \ No newline at end of file diff --git a/tests/asset-manifest.test.js b/tests/asset-manifest.test.js new file mode 100644 index 00000000..157335e2 --- /dev/null +++ b/tests/asset-manifest.test.js @@ -0,0 +1,85 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { writeAssetManifest, normalizeManifestPath, collectAssetManifest } = require('../postbuild.js'); +const { deepMerge } = require('../proxy/proxy-utils.mjs'); + +test('writeAssetManifest creates sorted relative manifest and ignores stale manifest', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'asset-manifest-')); + const outputRoot = path.join(tempRoot, 'dist', 'custom-module'); + fs.mkdirSync(path.join(outputRoot, 'assets', 'images', 'icons'), { recursive: true }); + + const files = [ + 'remoteEntry.js', + 'assets/custom.css', + 'assets/images/logo.svg', + 'assets/images/icons/test.svg', + ]; + + for (const relativePath of files) { + const fullPath = path.join(outputRoot, relativePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, 'content'); + } + + const staleManifestPath = path.join(outputRoot, 'asset-manifest.json'); + fs.writeFileSync(staleManifestPath, JSON.stringify({ files: ['old.js'], directories: ['old'] })); + + const manifestPath = writeAssetManifest(outputRoot); + const secondManifestPath = writeAssetManifest(outputRoot); + + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const secondManifest = JSON.parse(fs.readFileSync(secondManifestPath, 'utf8')); + assert.deepEqual(manifest, { + files: [ + 'assets/custom.css', + 'assets/images/icons/test.svg', + 'assets/images/logo.svg', + 'remoteEntry.js', + ], + directories: [ + 'assets', + 'assets/images', + 'assets/images/icons', + ], + }); + + assert.deepEqual(secondManifest, manifest); + assert.equal(new Set(manifest.directories).size, manifest.directories.length); + assert.equal(normalizeManifestPath(path.join(outputRoot, 'assets', 'images', 'logo.svg'), outputRoot), 'assets/images/logo.svg'); + assert.equal(collectAssetManifest(outputRoot).files.includes('asset-manifest.json'), false); + assert.equal(manifest.files.includes('asset-manifest.json'), false); + assert.equal(manifest.files.includes('old.js'), false); + assert.equal(manifest.directories.includes('old'), false); +}); + +test('deepMerge unions files and directories from remote and local manifests', () => { + const targetManifest = { + files: ['assets/landingpage/icon1.svg', 'assets/landingpage/search.svg'], + directories: ['assets/landingpage'], + }; + const localManifest = { + files: ['main.js', 'styles.css'], + directories: ['assets'], + }; + + const merged = deepMerge(targetManifest, localManifest); + + assert.deepEqual(merged.files, [ + 'assets/landingpage/icon1.svg', + 'assets/landingpage/search.svg', + 'main.js', + 'styles.css', + ]); + assert.deepEqual(merged.directories, ['assets/landingpage', 'assets']); +}); + +test('writeAssetManifest throws when output directory is missing', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'asset-manifest-missing-')); + const missingRoot = path.join(tempRoot, 'missing-output'); + + assert.throws(() => writeAssetManifest(missingRoot), /missing-output/); +}); diff --git a/tests/proxy.test.js b/tests/proxy.test.js new file mode 100644 index 00000000..a4bcb91d --- /dev/null +++ b/tests/proxy.test.js @@ -0,0 +1,21 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +test('resolveCustomModuleManifestPath handles IZ manifest requests and ignores central package manifests', async () => { + const { resolveCustomModuleManifestPath, isCustomModuleAssetManifestRequest, shouldProxyLandingPageAssetRequest } = await import('../proxy/proxy-utils.mjs'); + + const manifestPath = resolveCustomModuleManifestPath('/custom/TEST_INST-TEST_VIEW/asset-manifest.json', 'dist/custom-module'); + assert.equal(manifestPath, 'dist/custom-module/asset-manifest.json'); + + const centralManifestPath = resolveCustomModuleManifestPath('/custom/TEST_NZ-CENTRAL_PACKAGE/asset-manifest.json', 'dist/custom-module'); + assert.equal(centralManifestPath, null); + + assert.equal(isCustomModuleAssetManifestRequest('/custom/TEST_INST-TEST_VIEW/asset-manifest.json'), true); + assert.equal(isCustomModuleAssetManifestRequest('/custom/TEST_NZ-CENTRAL_PACKAGE/asset-manifest.json'), false); + assert.equal(isCustomModuleAssetManifestRequest('/custom/TEST_INST-TEST_VIEW/CENTRAL_CODE.txt'), false); + assert.equal(isCustomModuleAssetManifestRequest('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/custom/TEST_INST-TEST_VIEW/assets/example.png'), false); + + assert.equal(shouldProxyLandingPageAssetRequest('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/custom/TEST_INST-TEST_VIEW/assets/landingpage/icon.svg'), true); + assert.equal(shouldProxyLandingPageAssetRequest('/nde/custom/TEST_INST-TEST_VIEW/assets/landingpage/search.svg'), true); + assert.equal(shouldProxyLandingPageAssetRequest('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/custom/TEST_INST-TEST_VIEW/assets/main.js'), false); +}); From 27cd12ff9fddaa20ead9bcfe0fc58a3003f8c3cf Mon Sep 17 00:00:00 2001 From: "david.bendavid" Date: Mon, 10 Aug 2026 01:12:54 +0300 Subject: [PATCH 4/4] Add proxy asset manifest support --- postbuild.js | 1 - proxy/proxy-utils.mjs | 240 ++++++++++++++++++++++++++++------- proxy/proxy.conf.mjs | 2 +- tests/asset-manifest.test.js | 5 +- tests/proxy.test.js | 25 ++++ 5 files changed, 226 insertions(+), 47 deletions(-) diff --git a/postbuild.js b/postbuild.js index c5937666..50bf4f1d 100644 --- a/postbuild.js +++ b/postbuild.js @@ -96,7 +96,6 @@ function renameAndArchive() { try { fs.renameSync(distPath, targetPath); console.log(`Renamed directory to ${targetPath}`); - writeAssetManifest(targetPath); const output = fs.createWriteStream(zipPath); const archive = archiver('zip', { zlib: { level: 9 } }); diff --git a/proxy/proxy-utils.mjs b/proxy/proxy-utils.mjs index 93582870..46498941 100644 --- a/proxy/proxy-utils.mjs +++ b/proxy/proxy-utils.mjs @@ -76,6 +76,177 @@ export function isCustomModuleAssetManifestRequest(requestPath) { return !match[1].endsWith('-CENTRAL_PACKAGE'); } +export function isIzAssetManifestRequest(requestPath) { + return isCustomModuleAssetManifestRequest(requestPath); +} + +export function normalizeManifestPath(filePath, rootDirectory = process.cwd()) { + const absoluteRootDirectory = path.resolve(rootDirectory || process.cwd()); + const absoluteFilePath = path.resolve(filePath); + + const relativePath = path.relative(absoluteRootDirectory, absoluteFilePath); + if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return null; + } + + return relativePath.split(path.sep).join('/'); +} + +export function addParentDirectories(filePaths) { + const parentDirectories = new Set(); + + for (const filePath of filePaths) { + if (!filePath || typeof filePath !== 'string') { + continue; + } + + const normalized = filePath.split(/\\|\//).filter(Boolean).join('/'); + const parts = normalized.split('/').filter(Boolean); + for (let i = 1; i < parts.length; i += 1) { + const parent = parts.slice(0, i).join('/'); + if (parent) { + parentDirectories.add(parent); + } + } + } + + return [...parentDirectories].sort((a, b) => a.localeCompare(b)); +} + +export function createMergedAssetManifest(sourceManifests = []) { + const files = new Set(); + const directories = new Set(); + + for (const manifest of sourceManifests) { + if (!manifest || typeof manifest !== 'object') { + continue; + } + + const fileList = Array.isArray(manifest.files) ? manifest.files : []; + const directoryList = Array.isArray(manifest.directories) ? manifest.directories : []; + + for (const filePath of fileList) { + if (!filePath || typeof filePath !== 'string') { + continue; + } + + const normalized = filePath.replace(/^\/+/, '').split('\\').join('/'); + if (!normalized) { + continue; + } + + files.add(normalized); + } + + for (const directoryPath of directoryList) { + if (!directoryPath || typeof directoryPath !== 'string') { + continue; + } + + const normalized = directoryPath.replace(/^\/+/, '').split('\\').join('/'); + if (!normalized) { + continue; + } + + directories.add(normalized); + } + } + + for (const filePath of files) { + for (const parentDirectory of addParentDirectories([filePath])) { + directories.add(parentDirectory); + } + } + + return { + files: [...files].sort((a, b) => a.localeCompare(b)), + directories: [...directories].sort((a, b) => a.localeCompare(b)), + }; +} + +export function collectManifestFromDirectory(rootDirectory, manifestBase = '') { + const root = path.resolve(rootDirectory); + const output = { + files: new Set(), + directories: new Set(), + }; + + if (!fs.existsSync(root)) { + console.warn(`[AssetManifestProxy] Skipping missing source: ${root}`); + return output; + } + + if (!fs.statSync(root).isDirectory()) { + console.warn(`[AssetManifestProxy] Skipping non-directory source: ${root}`); + return output; + } + + console.log(`[AssetManifestProxy] Scanning: ${root}`); + + const stack = [root]; + while (stack.length > 0) { + const currentDirectory = stack.pop(); + const entries = fs.readdirSync(currentDirectory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + if (entry.name === 'asset-manifest.json') { + continue; + } + + const fullPath = path.join(currentDirectory, entry.name); + const relativeRootPath = normalizeManifestPath(fullPath, root); + if (!relativeRootPath) { + continue; + } + + const manifestPath = manifestBase + ? `${manifestBase.replace(/^\/+|\/+$/g, '')}/${relativeRootPath}` + : relativeRootPath; + + if (entry.isDirectory()) { + output.directories.add(manifestPath); + stack.push(fullPath); + } else if (entry.isFile()) { + output.files.add(manifestPath); + } + } + } + + return { + files: [...output.files], + directories: [...output.directories], + }; +} + +export function createLocalCustomModuleAssetManifest() { + const sources = []; + + const buildManifestRoot = getCustomModuleManifestRoot(); + sources.push({ root: buildManifestRoot, base: '' }); + + const srcAssetsRoot = path.resolve(process.cwd(), 'src', 'assets'); + if (fs.existsSync(srcAssetsRoot)) { + sources.push({ root: srcAssetsRoot, base: 'assets' }); + } else { + console.warn(`[AssetManifestProxy] Skipping missing source: ${srcAssetsRoot}`); + } + + const manifests = []; + for (const source of sources) { + const manifest = collectManifestFromDirectory(source.root, source.base); + if (manifest.files.length || manifest.directories.length) { + manifests.push({ + files: manifest.files, + directories: manifest.directories, + }); + } + } + + const merged = createMergedAssetManifest(manifests); + console.log(`[AssetManifestProxy] Returning merged manifest: ${merged.files.length} files, ${merged.directories.length} directories`); + return merged; +} + export function shouldProxyLandingPageRequest(requestPath) { const normalizedPath = (requestPath || '').split('?')[0].replace(/^\/+/, '/'); return normalizedPath === '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/nde/home' || normalizedPath === '/home'; @@ -97,11 +268,11 @@ export function buildMergedManifestResponse(requestPath, localManifestPath, targ const normalizedRequestPath = (requestPath || '').split('?')[0]; const targetManifestUrl = new URL(normalizedRequestPath.replace(/^\/+/, '/'), targetBaseUrl); const targetManifestUrlString = targetManifestUrl.toString(); - const localManifestPathResolved = path.resolve(localManifestPath); + const localManifestPathResolved = localManifestPath ? path.resolve(localManifestPath) : null; console.log(`[manifest] requestPath=${requestPath}`); console.log(`[manifest] targetUrl=${targetManifestUrlString}`); - console.log(`[manifest] localManifestPath=${localManifestPathResolved}`); + console.log(`[manifest] localManifestPath=${localManifestPathResolved || 'runtime-generated'}`); return new Promise((resolve, reject) => { const transport = targetManifestUrl.protocol === 'https:' ? https : http; @@ -114,12 +285,10 @@ export function buildMergedManifestResponse(requestPath, localManifestPath, targ if (response.statusCode && response.statusCode >= 200 && response.statusCode < 300 && body) { try { const targetManifest = JSON.parse(body); - const localManifest = fs.existsSync(localManifestPathResolved) - ? JSON.parse(fs.readFileSync(localManifestPathResolved, 'utf8')) - : { files: [], directories: [] }; + const runtimeLocalManifest = createLocalCustomModuleAssetManifest(); console.log('[manifest] target manifest read successfully'); - console.log('[manifest] local manifest read successfully'); - const mergedManifest = deepMerge(targetManifest, localManifest); + console.log('[manifest] local runtime manifest read successfully'); + const mergedManifest = deepMerge(targetManifest, runtimeLocalManifest); console.log('[manifest] merged manifest payload=', JSON.stringify(mergedManifest, null, 2)); resolve({ statusCode: 200, @@ -132,49 +301,34 @@ export function buildMergedManifestResponse(requestPath, localManifestPath, targ return; } - console.log('[manifest] target manifest unavailable or empty, falling back to local manifest'); - if (fs.existsSync(localManifestPathResolved)) { - try { - const localManifest = JSON.parse(fs.readFileSync(localManifestPathResolved, 'utf8')); - console.log('[manifest] local manifest fallback payload=', JSON.stringify(localManifest, null, 2)); - resolve({ - statusCode: 200, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(localManifest), - }); - } catch (error) { - reject(error); - } - return; - } - - resolve({ - statusCode: 404, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ error: `Manifest not found at ${localManifestPathResolved}` }), - }); - }); - }); - - request.on('error', (error) => { - console.log(`[manifest] target manifest request failed: ${error.message}`); - if (fs.existsSync(localManifestPathResolved)) { + console.log('[manifest] target manifest unavailable or empty, falling back to runtime-generated local manifest'); try { - const localManifest = JSON.parse(fs.readFileSync(localManifestPathResolved, 'utf8')); - console.log('[manifest] local manifest fallback payload=', JSON.stringify(localManifest, null, 2)); + const runtimeLocalManifest = createLocalCustomModuleAssetManifest(); + console.log('[manifest] local runtime manifest fallback payload=', JSON.stringify(runtimeLocalManifest, null, 2)); resolve({ statusCode: 200, headers: { 'content-type': 'application/json' }, - body: JSON.stringify(localManifest), + body: JSON.stringify(runtimeLocalManifest), }); - return; - } catch (readError) { - reject(readError); + } catch (error) { + reject(error); } - return; - } + }); + }); - reject(error); + request.on('error', (error) => { + console.log(`[manifest] target manifest request failed: ${error.message}`); + try { + const runtimeLocalManifest = createLocalCustomModuleAssetManifest(); + console.log('[manifest] local runtime manifest fallback payload=', JSON.stringify(runtimeLocalManifest, null, 2)); + resolve({ + statusCode: 200, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(runtimeLocalManifest), + }); + } catch (readError) { + reject(readError); + } }); }); } diff --git a/proxy/proxy.conf.mjs b/proxy/proxy.conf.mjs index 4add047c..3cf7fa71 100644 --- a/proxy/proxy.conf.mjs +++ b/proxy/proxy.conf.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs'; import {PROXY_TARGET} from "./proxy.const.mjs"; import {customizationConfigOverride} from "./customization_config_override.mjs"; -import {buildMergedManifestResponse, deepMerge, resolveCustomModuleManifestPath, shouldProxyLandingPageAssetRequest, shouldProxyLandingPageRequest} from "./proxy-utils.mjs"; +import {buildMergedManifestResponse, createLocalCustomModuleAssetManifest, deepMerge, isCustomModuleAssetManifestRequest, resolveCustomModuleManifestPath} from "./proxy-utils.mjs"; async function serveCustomModuleManifest(req, res) { const manifestPath = resolveCustomModuleManifestPath(req.url); diff --git a/tests/asset-manifest.test.js b/tests/asset-manifest.test.js index 157335e2..ff5e179f 100644 --- a/tests/asset-manifest.test.js +++ b/tests/asset-manifest.test.js @@ -5,7 +5,6 @@ const os = require('node:os'); const path = require('node:path'); const { writeAssetManifest, normalizeManifestPath, collectAssetManifest } = require('../postbuild.js'); -const { deepMerge } = require('../proxy/proxy-utils.mjs'); test('writeAssetManifest creates sorted relative manifest and ignores stale manifest', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'asset-manifest-')); @@ -56,7 +55,9 @@ test('writeAssetManifest creates sorted relative manifest and ignores stale mani assert.equal(manifest.directories.includes('old'), false); }); -test('deepMerge unions files and directories from remote and local manifests', () => { +test('deepMerge unions files and directories from remote and local manifests', async () => { + const { deepMerge } = await import('../proxy/proxy-utils.mjs'); + const targetManifest = { files: ['assets/landingpage/icon1.svg', 'assets/landingpage/search.svg'], directories: ['assets/landingpage'], diff --git a/tests/proxy.test.js b/tests/proxy.test.js index a4bcb91d..46531523 100644 --- a/tests/proxy.test.js +++ b/tests/proxy.test.js @@ -19,3 +19,28 @@ test('resolveCustomModuleManifestPath handles IZ manifest requests and ignores c assert.equal(shouldProxyLandingPageAssetRequest('/nde/custom/TEST_INST-TEST_VIEW/assets/landingpage/search.svg'), true); assert.equal(shouldProxyLandingPageAssetRequest('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/custom/TEST_INST-TEST_VIEW/assets/main.js'), false); }); + +test('createMergedAssetManifest merges dynamic IZ proxy sources and derives directories from files', async () => { + const { createMergedAssetManifest, normalizeManifestPath, addParentDirectories } = await import('../proxy/proxy-utils.mjs'); + + const manifestA = { + files: ['assets/css/custom.css', 'assets/images/a.svg'], + directories: ['assets', 'assets/css', 'assets/images'] + }; + + const manifestB = { + files: ['assets/css/custom.css', 'assets/images/b.svg'], + directories: ['assets', 'assets/css', 'assets/images'] + }; + + const merged = createMergedAssetManifest([manifestA, manifestB]); + + assert.deepEqual(merged.files, ['assets/css/custom.css', 'assets/images/a.svg', 'assets/images/b.svg']); + assert.deepEqual(merged.directories, ['assets', 'assets/css', 'assets/images']); + + const filePath = 'C:\\env\\nde\\mainCustomModule\\dist\\customModule\\assets\\images\\logo.svg'; + assert.equal(normalizeManifestPath(filePath, 'C:\\env\\nde\\mainCustomModule\\dist\\customModule'), 'assets/images/logo.svg'); + + const derived = addParentDirectories(['assets/images/icons/test.svg']); + assert.deepEqual(derived, ['assets', 'assets/images', 'assets/images/icons']); +});