Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions docs/decisions/0014-per-app-external-scripts.rst
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
}

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;
Comment thread
arbrandes marked this conversation as resolved.
}

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',
},
Comment thread
arbrandes marked this conversation as resolved.
},
// ...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.
4 changes: 4 additions & 0 deletions runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ export {
initialize
} from './initialize';

export {
loadExternalScripts
} from './scripts';

export {
configureLogging,
getLoggingService,
Expand Down
27 changes: 7 additions & 20 deletions runtime/initialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ import {
logError,
NewRelicLoggingService,
} from './logging';
import { GoogleAnalyticsLoader } from './scripts';
import { publish } from './subscriptions';
import { EnvironmentTypes } from '../types';

Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
Expand All @@ -283,7 +273,6 @@ export async function initialize({
analyticsService = SegmentAnalyticsService,
authService = AxiosJwtAuthService,
authMiddleware = [],
externalScripts = [GoogleAnalyticsLoader],
requireAuthenticatedUser: requireUser = false,
hydrateAuthenticatedUser: hydrateUser = false,
messages,
Expand All @@ -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;
Expand Down
77 changes: 0 additions & 77 deletions runtime/scripts/GoogleAnalyticsLoader.test.ts

This file was deleted.

59 changes: 0 additions & 59 deletions runtime/scripts/GoogleAnalyticsLoader.ts

This file was deleted.

13 changes: 12 additions & 1 deletion runtime/scripts/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,12 @@
export { default as GoogleAnalyticsLoader } from './GoogleAnalyticsLoader';
import { getAppConfig, getSiteConfig } from '../config';

export function loadExternalScripts() {
const apps = getSiteConfig().apps ?? [];
apps.forEach(app => {
const config = getAppConfig(app.appId) ?? {};
(app.externalScripts ?? []).forEach(ExternalScript => {
const script = new ExternalScript({ config });
Comment thread
brian-smith-tcril marked this conversation as resolved.
script.loadScript();
});
});
}
2 changes: 2 additions & 0 deletions shell/site.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
SITE_INIT_ERROR,
SITE_READY,
initialize,
loadExternalScripts,
subscribe
} from '../runtime';
import { addAppConfigs } from '../runtime/config';
Expand All @@ -30,6 +31,7 @@ subscribe(SITE_READY, async () => {
const router = createRouter();

addAppConfigs();
loadExternalScripts();

const root = createRoot(document.getElementById('root') as HTMLElement);
root.render(
Expand Down
9 changes: 9 additions & 0 deletions types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,19 @@ export interface App {
routes?: RoleRouteObject[],
providers?: AppProvider[],
slots?: SlotOperation[],
externalScripts?: ExternalScriptLoaderClass[],
config?: AppConfig,
provides?: Record<string, unknown>,
}

// External Scripts

export interface ExternalScriptLoader {
loadScript(): void,
}

export type ExternalScriptLoaderClass = new (data: { config: AppConfig }) => ExternalScriptLoader;
Comment thread
arbrandes marked this conversation as resolved.

// Site Config

export interface RequiredSiteConfig {
Expand Down
Loading