From cc35591c08627c3810a23738d5f647ec573f7e38 Mon Sep 17 00:00:00 2001 From: Volodymyr Malyhin Date: Thu, 2 Apr 2026 12:06:05 +0300 Subject: [PATCH 1/4] feat(NPF-4641): add js preload link to bundles --- ilc/server/app.spec.js | 63 ++++++++++++++++++++++ ilc/server/tailor/configs-injector.js | 27 +++++++++- ilc/server/tailor/configs-injector.spec.js | 52 +++++++++++++++++- 3 files changed, 140 insertions(+), 2 deletions(-) diff --git a/ilc/server/app.spec.js b/ilc/server/app.spec.js index 3e9d81067..9f2614c71 100644 --- a/ilc/server/app.spec.js +++ b/ilc/server/app.spec.js @@ -257,6 +257,69 @@ describe('App', () => { chai.expect(response.headers['x-custom-header']).to.be.undefined; }); + it('should emit JS preload Link headers for all SSR-rendered fragment entry bundles', async () => { + const { app: testApp, server: testServer } = await createTestServer({ + apps: { + '@portal/primary': { + spaBundle: 'http://localhost/primary.js', + kind: 'primary', + ssr: { src: 'http://apps.test/primary' }, + }, + '@portal/regular': { + spaBundle: 'http://localhost/regular.js', + kind: 'regular', + ssr: { src: 'http://apps.test/regular' }, + }, + }, + }); + + try { + const response = await testServer.get('/all').expect(200); + + chai.expect(response.headers.link).to.include( + '; rel="preload"; as="script"; nopush;', + ); + chai.expect(response.headers.link).to.include( + '; rel="preload"; as="script"; nopush;', + ); + chai.expect(response.headers.link).to.not.include('/_ilc/client.js'); + } finally { + testApp.server.close(); + } + }); + + it('should include wrapper entry bundle in JS preload Link headers', async () => { + const { app: testApp, server: testServer } = await createTestServer({ + apps: { + '@portal/wrappedApp': { + spaBundle: 'http://localhost/wrapped.js', + kind: 'primary', + ssr: { src: 'http://apps.test/wrappedApp' }, + wrappedWith: '@portal/wrapper', + }, + '@portal/wrapper': { + spaBundle: 'http://localhost/wrapper.js', + kind: 'wrapper', + ssr: { src: 'http://apps.test/wrapper' }, + props: { param1: 'value1' }, + }, + }, + }); + + try { + const response = await testServer.get('/wrapper').expect(200); + + chai.expect(response.headers.link).to.include( + '; rel="preload"; as="script"; nopush;', + ); + chai.expect(response.headers.link).to.include( + '; rel="preload"; as="script"; nopush;', + ); + } finally { + testApp.server.close(); + } + }); + it('should pass query parameters to fragment via routerProps', async () => { const response = await server.get('/primary?foo=bar&test=123').expect(200); diff --git a/ilc/server/tailor/configs-injector.js b/ilc/server/tailor/configs-injector.js index 06b4d6948..6d9ea8eca 100644 --- a/ilc/server/tailor/configs-injector.js +++ b/ilc/server/tailor/configs-injector.js @@ -75,6 +75,8 @@ module.exports = class ConfigsInjector { } request.styleRefs = this.#getRouteStyleRefsToPreload(registryConfig.apps, slots, template.styleRefs); + const fragmentsContext = request.router ? request.router.getFragmentsContext() : {}; + request.scriptRefs = this.#getSsrFragmentScriptRefsToPreload(fragmentsContext, registryConfig.apps); if (request.ldeRelated) { document = this.#removeProdTags(document); @@ -85,11 +87,34 @@ module.exports = class ConfigsInjector { getAssetsToPreload = async (request) => { return { - scriptRefs: [], + scriptRefs: request.scriptRefs || [], styleRefs: request.styleRefs, }; }; + #getSsrFragmentScriptRefsToPreload = (fragmentsContext, apps) => { + const scriptRefs = _.reduce( + _.values(fragmentsContext), + (result, fragmentContext) => { + if (fragmentContext.spaBundleUrl) { + result.push(fragmentContext.spaBundleUrl); + } + + const wrapperBundle = fragmentContext.wrapperConf?.name + ? apps[fragmentContext.wrapperConf.name]?.spaBundle + : undefined; + if (wrapperBundle) { + result.push(wrapperBundle); + } + + return result; + }, + [], + ); + + return uniqueArray(scriptRefs); + }; + #getRouteStyleRefsToPreload = (apps, slots, templateStyleRefs) => { const routeStyleRefs = _.reduce( slots, diff --git a/ilc/server/tailor/configs-injector.spec.js b/ilc/server/tailor/configs-injector.spec.js index 20e526272..c0c2a4b6f 100644 --- a/ilc/server/tailor/configs-injector.spec.js +++ b/ilc/server/tailor/configs-injector.spec.js @@ -18,7 +18,9 @@ describe('configs injector', () => { it('should return assets to preload', async () => { const styleRefs = ['firstStyleRef', 'secondStyleRef']; + const scriptRefs = ['firstScriptRef', 'secondScriptRef']; const request = { + scriptRefs, styleRefs, }; @@ -27,10 +29,11 @@ describe('configs injector', () => { const assetsToPreload = await configsInjector.getAssetsToPreload(request); chai.expect(assetsToPreload).to.be.eql({ - scriptRefs: [], + scriptRefs, styleRefs, }); chai.expect(request).to.be.eql({ + scriptRefs, styleRefs, }); }); @@ -386,6 +389,53 @@ describe('configs injector', () => { ); }); + it('should store SSR fragment entry bundles for response-level script preloads', () => { + context.run( + { + url: 'http://test/a?test=15', + domain: 'test.com', + requestId: 'requestId123', + path: '/a', + protocol: 'https', + }, + () => { + const configsInjector = new ConfigsInjector(newrelic); + const request = { + registryConfig, + router: { + getFragmentsContext: () => ({ + firstApp__at__firstSlot: { + spaBundleUrl: registryConfig.apps.firstApp.spaBundle, + wrapperConf: null, + }, + secondApp__at__secondSlot: { + spaBundleUrl: registryConfig.apps.secondApp.spaBundle, + wrapperConf: { + name: registryConfig.apps.secondApp.wrappedWith, + }, + }, + ssrOnlyApp__at__fifthSlot: { + spaBundleUrl: undefined, + wrapperConf: null, + }, + }), + }, + }; + const template = { + styleRefs: [], + content: '', + }; + + configsInjector.inject(request, template, { slots, reqUrl: '/test/route?a=15' }); + + chai.expect(request.scriptRefs).to.be.eql([ + registryConfig.apps.firstApp.spaBundle, + registryConfig.apps.secondApp.spaBundle, + ]); + }, + ); + }); + it('should allow setting attributes on html, head and body tags', () => { context.run( { From 6d22250794c9da260dc0dd46b97ed6940b1a6bfa Mon Sep 17 00:00:00 2001 From: Volodymyr Malyhin Date: Thu, 2 Apr 2026 14:09:48 +0300 Subject: [PATCH 2/4] chore: refactor to typescript --- ilc/server/tailor/configs-injector.js | 286 -------------------- ilc/server/tailor/configs-injector.ts | 372 ++++++++++++++++++++++++++ ilc/server/types/RegistryConfig.ts | 4 + 3 files changed, 376 insertions(+), 286 deletions(-) delete mode 100644 ilc/server/tailor/configs-injector.js create mode 100644 ilc/server/tailor/configs-injector.ts diff --git a/ilc/server/tailor/configs-injector.js b/ilc/server/tailor/configs-injector.js deleted file mode 100644 index 6d9ea8eca..000000000 --- a/ilc/server/tailor/configs-injector.js +++ /dev/null @@ -1,286 +0,0 @@ -const _ = require('lodash'); -const urljoin = require('url-join').default; -const { uniqueArray, encodeHtmlEntities } = require('../../common/utils'); -const { HrefLangService } = require('../services/HrefLangService'); -const { CanonicalTagService } = require('../services/CanonicalTagService'); - -module.exports = class ConfigsInjector { - #newrelic; - #nrCustomClientJsWrapper; - #nrAutomaticallyInjectClientScript; - #cdnUrl; - #jsInjectionPlaceholder = ''; - #cssInjectionPlaceholder = ''; - #markedProdTags = /.*?/gims; - - constructor(newrelic, cdnUrl = null, nrCustomClientJsWrapper = null, nrAutomaticallyInjectClientScript = true) { - this.#newrelic = newrelic; - this.#cdnUrl = cdnUrl; - this.#nrCustomClientJsWrapper = nrCustomClientJsWrapper; - this.#nrAutomaticallyInjectClientScript = nrAutomaticallyInjectClientScript; - } - - inject(request, template, route) { - const registryConfig = request.registryConfig; - const { slots, reqUrl: url } = route; - const locale = request.ilcState?.locale || registryConfig.settings?.i18n?.default?.locale; - - let document = template.content; - - if (typeof document !== 'string') { - throw new Error(`Can't inject ILC configs into invalid document.`); - } - - if (request.ilcState && request.ilcState.locale) { - document = document.replace('', ilcCss + ''); - } - - const hrefLangService = new HrefLangService(registryConfig.settings?.i18n); - - const hrefLangHtml = hrefLangService.getHrefLangsForUrlAsHTML(url); - const canonicalTagHtml = CanonicalTagService.getCanonicalTagForUrlAsHTML( - url, - locale, - registryConfig.settings?.i18n, - route.meta, - registryConfig.canonicalDomain, - ); - - const headHtmlContent = this.#wrapWithIgnoreDuringParsing( - //...routeAssets.scriptLinks, - this.#getIlcState(request), - this.#getSPAConfig(registryConfig), - ``, - this.#wrapWithAsyncScriptTag(this.#getClientjsUrl()), - this.#getNewRelicScript(), - hrefLangHtml, - canonicalTagHtml, - ); - - if (document.includes(this.#jsInjectionPlaceholder)) { - document = document.replace(this.#jsInjectionPlaceholder, headHtmlContent); - } else { - document = document.replace('', headHtmlContent + ''); - } - - request.styleRefs = this.#getRouteStyleRefsToPreload(registryConfig.apps, slots, template.styleRefs); - const fragmentsContext = request.router ? request.router.getFragmentsContext() : {}; - request.scriptRefs = this.#getSsrFragmentScriptRefsToPreload(fragmentsContext, registryConfig.apps); - - if (request.ldeRelated) { - document = this.#removeProdTags(document); - } - - return document; - } - - getAssetsToPreload = async (request) => { - return { - scriptRefs: request.scriptRefs || [], - styleRefs: request.styleRefs, - }; - }; - - #getSsrFragmentScriptRefsToPreload = (fragmentsContext, apps) => { - const scriptRefs = _.reduce( - _.values(fragmentsContext), - (result, fragmentContext) => { - if (fragmentContext.spaBundleUrl) { - result.push(fragmentContext.spaBundleUrl); - } - - const wrapperBundle = fragmentContext.wrapperConf?.name - ? apps[fragmentContext.wrapperConf.name]?.spaBundle - : undefined; - if (wrapperBundle) { - result.push(wrapperBundle); - } - - return result; - }, - [], - ); - - return uniqueArray(scriptRefs); - }; - - #getRouteStyleRefsToPreload = (apps, slots, templateStyleRefs) => { - const routeStyleRefs = _.reduce( - slots, - (styleRefs, slotData) => { - const appInfo = apps[slotData.appName]; - - if (appInfo.cssBundle && !styleRefs.includes(appInfo.cssBundle)) { - styleRefs.push(appInfo.cssBundle); - } - - return styleRefs; - }, - [], - ); - - const styleRefs = routeStyleRefs.concat(templateStyleRefs); - - return uniqueArray(styleRefs); - }; - - //TODO: add App Wrappers support - #getRouteAssets = (apps, slots) => { - const appsDependencies = _.reduce( - apps, - (dependencies, appInfo) => _.assign(dependencies, appInfo.dependencies), - {}, - ); - - const routeAssets = _.reduce( - slots, - (routeAssets, slotData) => { - const appInfo = apps[slotData.appName]; - - /** - * Need to save app's dependencies based on all merged apps dependencies - * to avoid duplicate vendors preloads on client side - * because apps may have common dependencies but from different sources - * - * @see {@path ilc/client/initIlcConfig.js} - */ - const appDependencies = _.reduce( - _.keys(appInfo.dependencies), - (appDependencies, dependencyName) => { - appDependencies[dependencyName] = appsDependencies[dependencyName]; - return appDependencies; - }, - {}, - ); - - routeAssets.dependencies = _.assign(routeAssets.dependencies, appDependencies); - - if (!_.includes(routeAssets.spaBundles, appInfo.spaBundle)) { - routeAssets.spaBundles.push(appInfo.spaBundle); - } - - if ( - appInfo.cssBundle && - !_.some(routeAssets.stylesheetLinks, (stylesheetLink) => - _.includes(stylesheetLink, appInfo.cssBundle), - ) - ) { - const stylesheetLink = this.#wrapWithFragmentStylesheetLink(appInfo.cssBundle, slotData.appName); - routeAssets.stylesheetLinks.push(stylesheetLink); - } - - return routeAssets; - }, - { spaBundles: [], dependencies: {}, stylesheetLinks: [] }, - ); - - const scriptRefs = _.concat( - [this.#getClientjsUrl()], - routeAssets.spaBundles, - _.values(routeAssets.dependencies), - ); - const withoutDuplicateScriptRefs = uniqueArray(scriptRefs).filter((scriptRef) => !!scriptRef); - - return { - scriptLinks: _.map(withoutDuplicateScriptRefs, this.#wrapWithLinkToPreloadScript), - stylesheetLinks: routeAssets.stylesheetLinks, - }; - }; - - #getClientjsUrl = () => (this.#cdnUrl === null ? '/_ilc/client.js' : urljoin(this.#cdnUrl, '/client.js')); - - #getSPAConfig = (registryConfig) => { - const apps = _.mapValues(registryConfig.apps, (v) => - _.pick(v, ['spaBundle', 'cssBundle', 'dependencies', 'props', 'kind', 'wrappedWith', 'l10nManifest']), - ); - - let settings = registryConfig.settings; - const customHTML = registryConfig.settings?.globalSpinner?.customHTML; - - if (customHTML) { - settings = { - ...registryConfig.settings, - globalSpinner: { - ...registryConfig.settings.globalSpinner, - customHTML: encodeHtmlEntities(customHTML), - }, - }; - } - - const routes = registryConfig.routes.map((v) => _.omit(v, ['routeId'])); - - let spaConfig = JSON.stringify({ - apps, - routes, - specialRoutes: _.mapValues(registryConfig.specialRoutes, (v) => _.omit(v, ['routeId'])), - settings, - sharedLibs: registryConfig.sharedLibs, - dynamicLibs: registryConfig.dynamicLibs, - canonicalDomain: registryConfig.canonicalDomain, - }); - - return ``; - }; - - #getIlcState = (request) => { - const state = request.ilcState || {}; - if (Object.keys(state).length === 0) { - return ''; - } - - return ``; - }; - - #wrapWithAsyncScriptTag = (url) => { - return ``; - }; - - #wrapWithLinkToPreloadScript = (url) => { - return ``; - }; - - #wrapWithFragmentStylesheetLink = (url, fragmentId) => { - return ``; - }; - - #getCrossoriginAttribute = (url) => { - return (this.#cdnUrl !== null && url.includes(this.#cdnUrl)) || url.includes('://') ? 'crossorigin' : ''; - }; - - #wrapWithIgnoreDuringParsing = (...content) => - `${content.join('')}`; - - // TODO(bc): this method should be removed next Major release, - // because this code contradicts multiple domains feature, where you usually expects having multiple NR apps on multiple domains - // and right now NR script is injected below other scripts, which is not recommended by NR. They recommend to inject as up as possible. - // So it is much easier to achieve needed outcome by modifying template. - // Right now gently backward compatibility is maintained, so everyone who expects NR to be injected in browser will get it. - #getNewRelicScript = () => { - if (!this.#nrAutomaticallyInjectClientScript) { - return ''; - } - - let nrCode = this.#newrelic.getBrowserTimingHeader(); - if (this.#nrCustomClientJsWrapper === null || !nrCode) { - return nrCode; - } - - nrCode = nrCode.replace(/(.*)<\/script\s*>/s, '$1'); - return this.#nrCustomClientJsWrapper.replace('%CONTENT%', nrCode); - }; - - #removeProdTags(content) { - return content.replace(this.#markedProdTags, ''); - } -}; diff --git a/ilc/server/tailor/configs-injector.ts b/ilc/server/tailor/configs-injector.ts new file mode 100644 index 000000000..8801b4797 --- /dev/null +++ b/ilc/server/tailor/configs-injector.ts @@ -0,0 +1,372 @@ +import urlJoin from 'url-join'; +import { RoutingStrategy, type IntlAdapterConfig } from 'ilc-sdk/app'; +import type { RouterMatch } from '../../common/types/Router'; +import { encodeHtmlEntities, uniqueArray } from '../../common/utils'; +import { HrefLangService } from '../services/HrefLangService'; +import { CanonicalTagService } from '../services/CanonicalTagService'; +import type { PatchedHttpRequest } from '../types/PatchedHttpRequest'; +import type { Template, TransformedRegistryConfig, TransformedSpecialRoute } from '../types/Registry'; +import type { App as RegistryApp } from '../types/RegistryConfig'; + +type ConfigsInjectorRequest = PatchedHttpRequest & { + registryConfig: TransformedRegistryConfig; + scriptRefs?: string[]; + styleRefs?: string[]; +}; + +type BrowserTimingHeaderProvider = { + getBrowserTimingHeader(): string; +}; + +type FragmentPreloadContext = Record; + +type PartialFragmentContextEntry = { + spaBundleUrl?: string; + wrapperConf?: { + name?: string; + } | null; +}; + +type InjectRoute = Pick; + +type PreloadAssets = { + scriptRefs: string[]; + styleRefs: string[] | undefined; +}; + +type RouteAssets = { + dependencies: Record; + spaBundles: string[]; + stylesheetLinks: string[]; +}; + +export class ConfigsInjector { + private readonly cdnUrl: string | null; + private readonly cssInjectionPlaceholder = ''; + private readonly jsInjectionPlaceholder = ''; + private readonly markedProdTags = /.*?/gims; + private readonly newrelic: BrowserTimingHeaderProvider; + private readonly nrAutomaticallyInjectClientScript: boolean; + private readonly nrCustomClientJsWrapper: string | null; + + constructor( + newrelic: BrowserTimingHeaderProvider, + cdnUrl: string | null = null, + nrCustomClientJsWrapper: string | null = null, + nrAutomaticallyInjectClientScript = true, + ) { + this.newrelic = newrelic; + this.cdnUrl = cdnUrl; + this.nrCustomClientJsWrapper = nrCustomClientJsWrapper; + this.nrAutomaticallyInjectClientScript = nrAutomaticallyInjectClientScript; + } + + inject(request: ConfigsInjectorRequest, template: Template, route: InjectRoute): string { + let document = template.content; + + if (typeof document !== 'string') { + throw new Error(`Can't inject ILC configs into invalid document.`); + } + + const registryConfig = request.registryConfig; + const { reqUrl: url, slots } = route; + const i18nConfig = this.getIntlAdapterConfig(registryConfig.settings.i18n); + const locale = request.ilcState?.locale ?? i18nConfig?.default.locale; + + if (request.ilcState?.locale) { + document = document.replace('', `${ilcCss}`); + } + + const hrefLangService = new HrefLangService(i18nConfig); + const hrefLangHtml = hrefLangService.getHrefLangsForUrlAsHTML(url); + const canonicalTagHtml = i18nConfig + ? CanonicalTagService.getCanonicalTagForUrlAsHTML( + url, + locale, + i18nConfig, + route.meta, + registryConfig.canonicalDomain, + ) + : ''; + + const headHtmlContent = this.wrapWithIgnoreDuringParsing( + this.getIlcState(request), + this.getSPAConfig(registryConfig), + '', + this.wrapWithAsyncScriptTag(this.getClientjsUrl()), + this.getNewRelicScript(), + hrefLangHtml, + canonicalTagHtml, + ); + + if (document.includes(this.jsInjectionPlaceholder)) { + document = document.replace(this.jsInjectionPlaceholder, headHtmlContent); + } else { + document = document.replace('', `${headHtmlContent}`); + } + + request.styleRefs = this.getRouteStyleRefsToPreload(registryConfig.apps, slots, template.styleRefs); + + const fragmentsContext: FragmentPreloadContext = request.router ? request.router.getFragmentsContext() : {}; + request.scriptRefs = this.getSsrFragmentScriptRefsToPreload(fragmentsContext, registryConfig.apps); + + if (request.ldeRelated) { + document = this.removeProdTags(document); + } + + return document; + } + + getAssetsToPreload = async (request: ConfigsInjectorRequest): Promise => { + return { + scriptRefs: request.scriptRefs ?? [], + styleRefs: request.styleRefs, + }; + }; + + private getSsrFragmentScriptRefsToPreload( + fragmentsContext: FragmentPreloadContext, + apps: Record, + ): string[] { + const scriptRefs: string[] = []; + + for (const fragmentContext of Object.values(fragmentsContext)) { + if (fragmentContext.spaBundleUrl) { + scriptRefs.push(fragmentContext.spaBundleUrl); + } + + const wrapperBundle = fragmentContext.wrapperConf?.name + ? apps[fragmentContext.wrapperConf.name]?.spaBundle + : undefined; + + if (wrapperBundle) { + scriptRefs.push(wrapperBundle); + } + } + + return uniqueArray(scriptRefs); + } + + private getRouteStyleRefsToPreload( + apps: Record, + slots: InjectRoute['slots'], + templateStyleRefs: string[], + ): string[] { + const routeStyleRefs: string[] = []; + + for (const slotData of Object.values(slots)) { + const appInfo = apps[slotData.appName]; + const cssBundle = appInfo?.cssBundle; + + if (cssBundle && !routeStyleRefs.includes(cssBundle)) { + routeStyleRefs.push(cssBundle); + } + } + + return uniqueArray(routeStyleRefs.concat(templateStyleRefs)); + } + + private getRouteAssets(apps: Record, slots: InjectRoute['slots']) { + const appsDependencies: Record = {}; + + for (const appInfo of Object.values(apps)) { + Object.assign(appsDependencies, appInfo.dependencies); + } + + const routeAssets: RouteAssets = { + dependencies: {}, + spaBundles: [], + stylesheetLinks: [], + }; + + for (const slotData of Object.values(slots)) { + const appInfo = apps[slotData.appName]; + + if (!appInfo) { + continue; + } + + for (const dependencyName of Object.keys(appInfo.dependencies ?? {})) { + const dependencyUrl = appsDependencies[dependencyName]; + if (dependencyUrl) { + routeAssets.dependencies[dependencyName] = dependencyUrl; + } + } + + if (appInfo.spaBundle && !routeAssets.spaBundles.includes(appInfo.spaBundle)) { + routeAssets.spaBundles.push(appInfo.spaBundle); + } + + if ( + appInfo.cssBundle && + !routeAssets.stylesheetLinks.some((stylesheetLink) => stylesheetLink.includes(appInfo.cssBundle!)) + ) { + routeAssets.stylesheetLinks.push( + this.wrapWithFragmentStylesheetLink(appInfo.cssBundle, slotData.appName), + ); + } + } + + const scriptRefs = uniqueArray([ + this.getClientjsUrl(), + ...routeAssets.spaBundles, + ...Object.values(routeAssets.dependencies), + ]).filter(Boolean); + + return { + scriptLinks: scriptRefs.map((scriptRef) => this.wrapWithLinkToPreloadScript(scriptRef)), + stylesheetLinks: routeAssets.stylesheetLinks, + }; + } + + private getClientjsUrl(): string { + return this.cdnUrl === null ? '/_ilc/client.js' : urlJoin(this.cdnUrl, '/client.js'); + } + + private getSPAConfig(registryConfig: TransformedRegistryConfig): string { + const apps = Object.fromEntries( + Object.entries(registryConfig.apps).map(([appName, appConfig]) => [ + appName, + { + spaBundle: appConfig.spaBundle, + cssBundle: appConfig.cssBundle, + dependencies: appConfig.dependencies, + props: appConfig.props, + kind: appConfig.kind, + wrappedWith: appConfig.wrappedWith, + l10nManifest: appConfig.l10nManifest, + }, + ]), + ); + + let settings = registryConfig.settings; + const customHTML = registryConfig.settings.globalSpinner?.customHTML; + + if (customHTML) { + settings = { + ...registryConfig.settings, + globalSpinner: { + ...registryConfig.settings.globalSpinner, + customHTML: encodeHtmlEntities(customHTML), + }, + }; + } + + const routes = registryConfig.routes.map((route) => { + const { routeId, ...routeWithoutId } = route; + return routeWithoutId; + }); + + const specialRoutes = Object.fromEntries( + Object.entries(registryConfig.specialRoutes).map(([specialRouteKey, specialRoute]) => [ + specialRouteKey, + this.omitRouteId(specialRoute), + ]), + ); + + const spaConfig = JSON.stringify({ + apps, + routes, + specialRoutes, + settings, + sharedLibs: registryConfig.sharedLibs, + dynamicLibs: registryConfig.dynamicLibs, + canonicalDomain: registryConfig.canonicalDomain, + }); + + return ``; + } + + private getIlcState(request: ConfigsInjectorRequest): string { + const state = request.ilcState ?? {}; + + if (Object.keys(state).length === 0) { + return ''; + } + + return ``; + } + + private wrapWithAsyncScriptTag(url: string): string { + return ``; + } + + private wrapWithLinkToPreloadScript(url: string): string { + return ``; + } + + private wrapWithFragmentStylesheetLink(url: string, fragmentId: string): string { + return ``; + } + + private getCrossoriginAttribute(url: string): string { + return (this.cdnUrl !== null && url.includes(this.cdnUrl)) || url.includes('://') ? 'crossorigin' : ''; + } + + private wrapWithIgnoreDuringParsing(...content: string[]): string { + return `${content.join('')}`; + } + + private getNewRelicScript(): string { + if (!this.nrAutomaticallyInjectClientScript) { + return ''; + } + + let nrCode = this.newrelic.getBrowserTimingHeader(); + + if (this.nrCustomClientJsWrapper === null || !nrCode) { + return nrCode; + } + + nrCode = nrCode.replace(/(.*)<\/script\s*>/s, '$1'); + return this.nrCustomClientJsWrapper.replace('%CONTENT%', nrCode); + } + + private removeProdTags(content: string): string { + return content.replace(this.markedProdTags, ''); + } + + private omitRouteId(route: TransformedSpecialRoute): Omit { + const { routeId, ...routeWithoutId } = route; + return routeWithoutId; + } + + private getIntlAdapterConfig( + i18nConfig: TransformedRegistryConfig['settings']['i18n'], + ): IntlAdapterConfig | undefined { + const routingStrategy = i18nConfig?.routingStrategy; + + if ( + !i18nConfig || + typeof i18nConfig.default?.locale !== 'string' || + typeof i18nConfig.default?.currency !== 'string' || + !Array.isArray(i18nConfig.supported?.locale) || + !Array.isArray(i18nConfig.supported?.currency) || + (routingStrategy !== RoutingStrategy.Prefix && routingStrategy !== RoutingStrategy.PrefixExceptDefault) + ) { + return undefined; + } + + return { + default: { + currency: i18nConfig.default.currency, + locale: i18nConfig.default.locale, + }, + routingStrategy, + supported: { + currency: i18nConfig.supported.currency, + locale: i18nConfig.supported.locale, + }, + }; + } +} diff --git a/ilc/server/types/RegistryConfig.ts b/ilc/server/types/RegistryConfig.ts index 6722c6fb5..99c2ba066 100644 --- a/ilc/server/types/RegistryConfig.ts +++ b/ilc/server/types/RegistryConfig.ts @@ -1,6 +1,7 @@ import type { Route, SpecialRoute } from '../../common/types/Router'; export type App = { + dependencies?: Record; kind?: string; ssr?: { timeout?: number; @@ -27,6 +28,9 @@ export interface RegistryConfig { settings: { trailingSlash?: string; overrideConfigTrustedOrigins?: string; + globalSpinner?: { + customHTML?: string; + }; i18n?: { enabled?: boolean; default?: { From 454cfb8c145e526aebcbc0203586038010c5757d Mon Sep 17 00:00:00 2001 From: Volodymyr Malyhin Date: Thu, 2 Apr 2026 14:45:39 +0300 Subject: [PATCH 3/4] fix: fix running tests --- ilc/server/tailor/configs-injector.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ilc/server/tailor/configs-injector.ts b/ilc/server/tailor/configs-injector.ts index 8801b4797..5a1537440 100644 --- a/ilc/server/tailor/configs-injector.ts +++ b/ilc/server/tailor/configs-injector.ts @@ -40,7 +40,7 @@ type RouteAssets = { stylesheetLinks: string[]; }; -export class ConfigsInjector { +class ConfigsInjector { private readonly cdnUrl: string | null; private readonly cssInjectionPlaceholder = ''; private readonly jsInjectionPlaceholder = ''; @@ -370,3 +370,5 @@ export class ConfigsInjector { }; } } + +export = ConfigsInjector; From f7251e9adc9a4a5e6904b377d93da734cc657c24 Mon Sep 17 00:00:00 2001 From: Volodymyr Malyhin Date: Thu, 2 Apr 2026 14:58:50 +0300 Subject: [PATCH 4/4] chore: refactor using export class --- ilc/server/tailor/configs-injector.spec.js | 2 +- ilc/server/tailor/configs-injector.ts | 4 +--- ilc/server/tailor/factory.js | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/ilc/server/tailor/configs-injector.spec.js b/ilc/server/tailor/configs-injector.spec.js index c0c2a4b6f..b25ac16a9 100644 --- a/ilc/server/tailor/configs-injector.spec.js +++ b/ilc/server/tailor/configs-injector.spec.js @@ -5,7 +5,7 @@ const _fp = require('lodash/fp'); const LZUTF8 = require('lzutf8'); const { context } = require('../context/context'); -const ConfigsInjector = require('./configs-injector'); +const { ConfigsInjector } = require('./configs-injector'); describe('configs injector', () => { const newrelic = { diff --git a/ilc/server/tailor/configs-injector.ts b/ilc/server/tailor/configs-injector.ts index 5a1537440..8801b4797 100644 --- a/ilc/server/tailor/configs-injector.ts +++ b/ilc/server/tailor/configs-injector.ts @@ -40,7 +40,7 @@ type RouteAssets = { stylesheetLinks: string[]; }; -class ConfigsInjector { +export class ConfigsInjector { private readonly cdnUrl: string | null; private readonly cssInjectionPlaceholder = ''; private readonly jsInjectionPlaceholder = ''; @@ -370,5 +370,3 @@ class ConfigsInjector { }; } } - -export = ConfigsInjector; diff --git a/ilc/server/tailor/factory.js b/ilc/server/tailor/factory.js index b0fc13680..ba51c4a93 100644 --- a/ilc/server/tailor/factory.js +++ b/ilc/server/tailor/factory.js @@ -8,7 +8,7 @@ const { fetchTemplate } = require('./fetch-template'); const { filterHeaders } = require('./filter-headers'); const errorHandlerSetup = require('./error-handler'); const fragmentHooks = require('./fragment-hooks'); -const ConfigsInjector = require('./configs-injector'); +const { ConfigsInjector } = require('./configs-injector'); const processFragmentResponse = require('./process-fragment-response'); const requestFragment = require('./request-fragment');