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
2 changes: 2 additions & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type {Plug} from './plug';
import type {PluginFactory} from './plugin';

declare global {
type CroctCallback = (instance: Plug) => void;

interface Window {
croct?: Plug;
onCroctLoad: CroctCallback | undefined;
croctPlugins?: Record<string, PluginFactory>;
}
}
279 changes: 209 additions & 70 deletions src/plug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {FetchOptions as BaseFetchOptions} from '@croct/sdk/facade/contentFe
import type {FetchResponseOptions, FetchResponse as BaseFetchResponse} from '@croct/sdk/contentFetcher';
import {loadSlotContent} from '@croct/content';
import type {Plugin, PluginArguments, PluginFactory} from './plugin';
import {isReservedPluginName} from './plugin';
import {CDN_URL} from './constants';
import {factory as previewPluginFactory} from './plugins/preview';
import {factory as autoTrackingPluginFactory} from './plugins/autoTracking';
Expand Down Expand Up @@ -86,6 +87,21 @@ export interface Plug {

const PLUGIN_NAMESPACE = 'Plugin';

const objectHasOwnProperty = Object.prototype.hasOwnProperty;

function hasOwn(target: object, property: PropertyKey): boolean {
return objectHasOwnProperty.call(target, property);
}

/**
* The state of a single plug session needed to enable plugins.
*/
type PluginContext = {
sdk: SdkFacade,
appId: string,
logger: Logger,
};

function detectAppId(): string | null {
const script = window.document.querySelector(`script[src^='${CDN_URL}']`);

Expand Down Expand Up @@ -120,7 +136,11 @@ export class GlobalPlug implements Plug {
}

public extend(name: string, plugin: PluginFactory): void {
if (this.pluginFactories[name] !== undefined) {
if (isReservedPluginName(name)) {
throw new Error(`The plugin name "${name}" is reserved and cannot be used.`);
}

if (hasOwn(this.pluginFactories, name)) {
throw new Error(`Another plugin is already registered with name "${name}".`);
}

Expand Down Expand Up @@ -186,105 +206,224 @@ export class GlobalPlug implements Plug {
);
}

const configurations = plugins ?? {};
const context: PluginContext = {
sdk: sdk,
appId: appId,
logger: logger,
};

this.watchPluginRegistry(context, configurations);

const pending: Array<Promise<void>> = [];

const defaultEnabledPlugins = Object.fromEntries(
Object.keys(this.pluginFactories)
.map(name => [name, true]),
);

for (const [name, options] of Object.entries({...defaultEnabledPlugins, ...plugins})) {
logger.debug(`Initializing plugin "${name}"...`);
for (const [name, options] of Object.entries({...defaultEnabledPlugins, ...configurations})) {
const promise = this.enablePlugin(name, options, context);

const factory = this.pluginFactories[name];
if (promise instanceof Promise) {
pending.push(promise);
}
}

if (factory === undefined) {
logger.error(`Plugin "${name}" is not registered.`);
Promise.all(pending)
.then(() => {
this.initialize();

continue;
}
logger.debug('Initialization complete');
});
}

if (typeof options !== 'boolean' && (options === null || typeof options !== 'object')) {
logger.error(
`Invalid options for plugin "${name}", `
+ `expected either boolean or object but got ${describe(options)}`,
);
/**
* Registers plugins declared on the `window.croctPlugins` global registry.
*
* Plugins already present are registered eagerly so they are enabled as part
* of the regular initialization. The registry is then replaced with a proxy
* that registers and enables any plugin declared after the plug is loaded,
* making the registration order irrelevant.
*
* The proxy traps the captured session: once the plug is unplugged or plugged
* again, the `this.instance === context.sdk` check turns it inert, so no
* registry state has to be reset on the instance.
*/
private watchPluginRegistry(context: PluginContext, configurations: PluginConfigurations): void {
const {sdk, logger} = context;

// Spread into a fresh object so a proxy installed by a previous session is
// discarded rather than re-wrapped, preventing proxies from nesting.
const registry = {...window.croctPlugins};

for (const [name, factory] of Object.entries(registry)) {
this.registerExternalPlugin(name, factory, logger);
}

continue;
}
window.croctPlugins = new Proxy(registry, {
set: (target, property, factory): boolean => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since plugins can't be unregistered or overridden I'd also add a delete handler with an error log stating that the plugin will remain registered.

Currently it will log only when trying to set it to something else.

const result = Reflect.set(target, property, factory);

if (options === false) {
logger.warn(`Plugin "${name}" is declared but not enabled`);
if (result && this.instance === sdk && typeof property === 'string') {
if (this.registerExternalPlugin(property, factory, logger)) {
// Read the options as an own property only, so inherited keys
// such as `toString` are treated as "not configured".
const options = hasOwn(configurations, property) ? configurations[property] : true;

continue;
}
void this.enablePlugin(property, options, context);
}
}

const args: PluginArguments = {
options: options === true ? {} : options,
sdk: {
version: VERSION,
appId: appId,
plug: this,
tracker: sdk.tracker,
evaluator: sdk.evaluator,
user: sdk.user,
session: sdk.session,
tab: sdk.context.getTab(),
userTokenStore: {
getToken: sdk.getToken.bind(sdk),
setToken: sdk.setToken.bind(sdk),
},
previewTokenStore: sdk.previewTokenStore,
cidAssigner: sdk.cidAssigner,
eventManager: sdk.eventManager,
getLogger: (...namespace: string[]): Logger => sdk.getLogger(PLUGIN_NAMESPACE, name, ...namespace),
getTabStorage: (...namespace: string[]): Storage => (
sdk.getTabStorage(PLUGIN_NAMESPACE, name, ...namespace)
),
getBrowserStorage: (...namespace: string[]): Storage => (
sdk.getBrowserStorage(PLUGIN_NAMESPACE, name, ...namespace)
),
},
};
return result;
},
deleteProperty: (target, property): boolean => {
if (this.instance === sdk && typeof property === 'string' && hasOwn(this.pluginFactories, property)) {
logger.error(
`Plugin "${property}" cannot be unregistered; `
+ 'it will remain registered until the page is reloaded.',
);
}

return Reflect.deleteProperty(target, property);
},
});
}

let plugin;
/**
* Registers a plugin factory declared on the global registry.
*
* Unlike {@link extend}, conflicts are tolerated so a single malformed or
* duplicated declaration cannot prevent the plug from initializing. A factory
* already registered under the same name is silently ignored, allowing the
* registry to be safely re-scanned across re-plugs.
*
* @returns Whether the factory was newly registered.
*/
private registerExternalPlugin(name: string, factory: unknown, logger: Logger): boolean {
if (isReservedPluginName(name)) {
logger.error(`The plugin name "${name}" is reserved and cannot be used, ignoring it.`);

return false;
}

try {
plugin = factory(args);
} catch (error) {
logger.error(`Failed to initialize plugin "${name}": ${formatCause(error)}`);
if (typeof factory !== 'function') {
logger.error(`The plugin "${name}" declared globally is not a valid factory, ignoring it.`);

continue;
}
return false;
}

logger.debug(`Plugin "${name}" initialized`);
const registered = hasOwn(this.pluginFactories, name) ? this.pluginFactories[name] : undefined;

if (typeof plugin !== 'object') {
continue;
if (registered !== undefined) {
if (registered !== factory) {
logger.warn(`Plugin "${name}" is already registered, ignoring global registration.`);
}

this.plugins[name] = plugin;
return false;
}

const promise = plugin.enable();
this.pluginFactories[name] = factory as PluginFactory;

if (!(promise instanceof Promise)) {
logger.debug(`Plugin "${name}" enabled`);
return true;
}

continue;
}
/**
* Instantiates and enables a single plugin.
*
* @returns A promise that resolves once the plugin is enabled, or nothing if
* the plugin is synchronous, disabled, or could not be initialized.
*/
private enablePlugin(name: string, options: unknown, context: PluginContext): Promise<void> | void {
const {sdk, appId, logger} = context;

pending.push(
promise.then(() => logger.debug(`Plugin "${name}" enabled`))
.catch(error => logger.error(`Failed to enable plugin "${name}": ${formatCause(error)}`)),
if (isReservedPluginName(name)) {
logger.error(`The plugin name "${name}" is reserved and cannot be used, ignoring it.`);

return;
}

logger.debug(`Initializing plugin "${name}"...`);

Comment thread
marcospassos marked this conversation as resolved.
const factory = hasOwn(this.pluginFactories, name) ? this.pluginFactories[name] : undefined;

if (factory === undefined) {
logger.error(`Plugin "${name}" is not registered.`);

return;
}

if (typeof options !== 'boolean' && (options === null || typeof options !== 'object')) {
logger.error(
`Invalid options for plugin "${name}", `
+ `expected either boolean or object but got ${describe(options)}`,
);

return;
}

Promise.all(pending)
.then(() => {
this.initialize();
if (options === false) {
logger.warn(`Plugin "${name}" is declared but not enabled`);

logger.debug('Initialization complete');
});
return;
}

const args: PluginArguments = {
options: options === true ? {} : options,
sdk: {
version: VERSION,
appId: appId,
plug: this,
tracker: sdk.tracker,
evaluator: sdk.evaluator,
user: sdk.user,
session: sdk.session,
tab: sdk.context.getTab(),
userTokenStore: {
getToken: sdk.getToken.bind(sdk),
setToken: sdk.setToken.bind(sdk),
},
previewTokenStore: sdk.previewTokenStore,
cidAssigner: sdk.cidAssigner,
eventManager: sdk.eventManager,
getLogger: (...namespace: string[]): Logger => sdk.getLogger(PLUGIN_NAMESPACE, name, ...namespace),
getTabStorage: (...namespace: string[]): Storage => (
sdk.getTabStorage(PLUGIN_NAMESPACE, name, ...namespace)
),
getBrowserStorage: (...namespace: string[]): Storage => (
sdk.getBrowserStorage(PLUGIN_NAMESPACE, name, ...namespace)
),
},
};

let plugin;

try {
plugin = factory(args);
} catch (error) {
logger.error(`Failed to initialize plugin "${name}": ${formatCause(error)}`);

return;
}

logger.debug(`Plugin "${name}" initialized`);

if (typeof plugin !== 'object') {
return;
}

this.plugins[name] = plugin;

const promise = plugin.enable();

if (!(promise instanceof Promise)) {
logger.debug(`Plugin "${name}" enabled`);

return;
}

return promise.then(() => logger.debug(`Plugin "${name}" enabled`))
.catch(error => logger.error(`Failed to enable plugin "${name}": ${formatCause(error)}`));
}

public get initialized(): boolean {
Expand Down
24 changes: 24 additions & 0 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,30 @@ export interface PluginSdk {
getBrowserStorage(...namespace: string[]): Storage;
}

const RESERVED_PLUGIN_NAMES: readonly string[] = ['__proto__', 'constructor', 'prototype'];

export function isReservedPluginName(name: string): boolean {
return RESERVED_PLUGIN_NAMES.includes(name);
}

export namespace PluginSdk {
export function register(name: string, factory: PluginFactory): void {
if (isReservedPluginName(name)) {
throw new Error(`The plugin name "${name}" is reserved and cannot be used.`);
}

if (typeof window === 'undefined') {
return;
}

if (window.croctPlugins === undefined) {
window.croctPlugins = {};
}

window.croctPlugins[name] = factory;
}
}

export interface PluginArguments<T = any> {
options: T;
sdk: PluginSdk;
Expand Down
Loading
Loading