diff --git a/docs/decisions/0014-per-app-external-scripts.rst b/docs/decisions/0014-per-app-external-scripts.rst new file mode 100644 index 00000000..6d81c129 --- /dev/null +++ b/docs/decisions/0014-per-app-external-scripts.rst @@ -0,0 +1,122 @@ +Per-App External Scripts +######################### + +Status +====== + +Proposed + + +Context +======= + +frontend-base previously loaded external scripts during the ``initialize()`` +call, with a hardcoded default. The list of external scripts was not +configurable by operators, meaning there was no way to add, remove, or replace +scripts without modifying the platform itself. The hardcoded default also +meant every site paid the cost of bundling it whether it was used or not. + + +Decision +======== + +Add an optional ``externalScripts`` field to the ``App`` interface:: + + export interface App { + appId: string, + routes?: RoleRouteObject[], + providers?: AppProvider[], + slots?: SlotOperation[], + externalScripts?: ExternalScriptLoaderClass[], + config?: AppConfig, + provides?: Record, + } + +Each entry is a class that implements ``ExternalScriptLoader``:: + + export interface ExternalScriptLoader { + loadScript(): void, + } + + export type ExternalScriptLoaderClass = + new (data: { config: AppConfig }) => ExternalScriptLoader; + +During initialization, the runtime iterates all apps and instantiates each +app's external scripts with that app's merged config (``commonAppConfig`` + +per-app ``config``). Scripts from different apps accumulate without clobbering +each other, following the same pattern as ``routes`` and ``slots``. + +frontend-base itself ships no external script loaders. Operators write their +own (or pull them from a third-party package) and attach them to an app in +their ``site.config``. + + +Consequences +============ + +Operators who want to load an external script author an ``ExternalScriptLoader`` +class and attach it to one of their apps via ``externalScripts``. The +configuration the loader reads (e.g. an analytics ID) lives in that app's +``config`` or in ``commonAppConfig``, not as a top-level ``SiteConfig`` key. + +Because loaders are per-app, multiple apps can each contribute their own +scripts without conflict, and an app that doesn't need any scripts doesn't +pay the cost of loaders it doesn't use. + + +Example +======= + +An operator-authored loader (for instance, a third-party consent banner) and +its wiring in ``site.config``:: + + class ConsentBannerLoader { + constructor({ config }) { + this.siteId = config.consentBannerSiteId; + } + + loadScript() { + if (!this.siteId) return; + const script = document.createElement('script'); + script.id = 'consent-banner'; + script.src = `https://example.com/client_data/${this.siteId}/script.js`; + document.head.appendChild(script); + } + } + + export default { + apps: [ + { + appId: 'org.example.app.consentBanner', + externalScripts: [ConsentBannerLoader], + config: { + consentBannerSiteId: 'YOUR_SITE_ID', + }, + }, + // ...other apps + ], + }; + +The ``consentBannerSiteId`` value shown above is a static default. Operators +can override it at runtime without modifying ``site.config``. + + +Rejected alternatives +===================== + +Site-level ``externalScripts`` +------------------------------ + +An earlier iteration added ``externalScripts`` to ``OptionalSiteConfig``, +letting operators override scripts at the site level. This was rejected +because a site-level array replaces rather than composes: there is no way for +multiple apps to each contribute their own scripts independently. + +A ``contrib/`` directory for pre-built loaders +----------------------------------------------- + +An earlier iteration moved ``GoogleAnalyticsLoader`` to a new top-level +``contrib/`` directory for optional, pre-built apps. This was rejected +because a single loader does not justify a new directory, its build wiring, +and a category of code; operators can author their own loaders with no loss +of functionality. diff --git a/runtime/index.ts b/runtime/index.ts index 1235d756..2cc94a7d 100644 --- a/runtime/index.ts +++ b/runtime/index.ts @@ -85,6 +85,10 @@ export { initialize } from './initialize'; +export { + loadExternalScripts +} from './scripts'; + export { configureLogging, getLoggingService, diff --git a/runtime/initialize.js b/runtime/initialize.js index 418eb56c..26b3974a 100644 --- a/runtime/initialize.js +++ b/runtime/initialize.js @@ -88,7 +88,6 @@ import { logError, NewRelicLoggingService, } from './logging'; -import { GoogleAnalyticsLoader } from './scripts'; import { publish } from './subscriptions'; import { EnvironmentTypes } from '../types'; @@ -191,13 +190,6 @@ async function runtimeConfig() { } } -export function loadExternalScripts(externalScripts, data) { - externalScripts.forEach(ExternalScript => { - const script = new ExternalScript(data); - script.loadScript(); - }); -} - /** * The default handler for the initialization lifecycle's `analytics` phase. * @@ -262,8 +254,6 @@ function applyOverrideHandlers(overrides) { * @param {*} [options.analyticsService=SegmentAnalyticsService] The `AnalyticsService` * implementation to use. * @param {*} [options.authMiddleware=[]] An array of middleware to apply to http clients in the auth service. - * @param {*} [options.externalScripts=[GoogleAnalyticsLoader]] An array of externalScripts. - * By default added GoogleAnalyticsLoader. * @param {*} [options.requireAuthenticatedUser=false] If true, turns on automatic login * redirection for unauthenticated users. Defaults to false, meaning that by default the * application will allow anonymous/unauthenticated sessions. @@ -283,7 +273,6 @@ export async function initialize({ analyticsService = SegmentAnalyticsService, authService = AxiosJwtAuthService, authMiddleware = [], - externalScripts = [GoogleAnalyticsLoader], requireAuthenticatedUser: requireUser = false, hydrateAuthenticatedUser: hydrateUser = false, messages, @@ -301,15 +290,13 @@ export async function initialize({ await runtimeConfig(); publish(SITE_CONFIG_INITIALIZED); - loadExternalScripts(externalScripts, { - config: getSiteConfig(), - }); - - // This allows us to replace the implementations of the logging, analytics, and auth services - // based on keys in the SiteConfig. The JavaScript File Configuration method is the only - // one capable of supplying an alternate implementation since it can import other modules. - // If a service wasn't supplied we fall back to the default parameters on the initialize - // function signature. + /* + * This allows us to replace the implementations of the logging, analytics, and auth + * services based on keys in the SiteConfig. The JavaScript File Configuration method is + * the only one capable of supplying an alternate implementation since it can import other + * modules. If a service wasn't supplied we fall back to the default parameters on the + * initialize function signature. + */ const loggingServiceImpl = getSiteConfig().loggingService ?? loggingService; const analyticsServiceImpl = getSiteConfig().analyticsService ?? analyticsService; const authServiceImpl = getSiteConfig().authService ?? authService; diff --git a/runtime/scripts/GoogleAnalyticsLoader.test.ts b/runtime/scripts/GoogleAnalyticsLoader.test.ts deleted file mode 100644 index a86d484a..00000000 --- a/runtime/scripts/GoogleAnalyticsLoader.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { GoogleAnalyticsLoader } from './index'; - -const googleAnalyticsId = 'test-key'; - -describe('GoogleAnalytics', () => { - let body; - let gaScriptSrc; - let gaScriptGtag; - let data; - - beforeEach(() => { - // @ts-expect-error We're adding it, this is fine. - window.googleAnalytics = []; - }); - - function loadGoogleAnalytics(scriptData) { - const script = new GoogleAnalyticsLoader(scriptData); - script.loadScript(); - } - - describe('with valid GOOGLE_ANALYTICS_4_ID', () => { - beforeEach(() => { - document.body.innerHTML = '