apexcharts
Version:
A JavaScript Chart Library
347 lines (308 loc) • 12.8 kB
JavaScript
// @ts-check
/**
* LicenseEnforcer - decides, per chart, whether a trial watermark is shown.
*
* Gates these premium features, and ONLY when they are actually IN USE (not
* merely bundled): storyboard, link (crossfilter/linked views), ink, measure,
* context-menu, perspectives, history, PLUS the premium `unit` (dot-cluster /
* pictogram) chart type. Everything else (all OTHER chart types, and the free
* modules weave / renderer-canvas / marks / facet / drilldown / morph /
* annotations / legend / toolbar / keyboard / exports, and the always-on core)
* is never gated.
*
* Enforcement is trial-mode: a premium feature without a valid license keeps
* working, but the chart shows an "APEXCHARTS" watermark. A valid key removes
* it. Client-side enforcement is inherently bypassable - this is deterrence and
* honest-customer compliance, not DRM. It must NEVER degrade or block a feature
* and must NEVER throw.
*
* @module modules/license/LicenseEnforcer
*/
import { LicenseManager } from './LicenseManager'
import { Watermark } from './Watermark'
import { Environment } from '../../utils/Environment.js'
const PRICING_URL = 'https://apexcharts.com/pricing'
// Process-global "used" signals for API-only premium paths that have no chart
// context at call time (the static perspectives decode/fromURL entry points).
let _perspectivesTokenDecoded = false
/**
* Every chart currently using a premium feature, so an async signature verdict
* can correct the watermark decision made synchronously during render.
*
* This exists because `Apex._chartInstances` is not the right list: a chart only
* joins it when the user declares `chart.id`, which is optional and uncommon.
* Reconciling through that list alone meant a forged key escaped the watermark
* entirely on every anonymous chart, since the provisional verdict said "valid"
* and nothing ever asked again. Membership is tracked here instead, keyed on
* premium usage rather than on the user having named the chart.
*
* Entries are removed on destroy() and pruned during reconciliation.
*/
const enforced = new Set()
/**
* Stop reconciling a chart. Called from destroy(), which cannot use
* `teardownWatermark` for this: that also runs on the valid-licence path, where
* dropping the chart would reintroduce the very gap described above.
* @param {any} ctx
*/
export function untrackChart(ctx) {
enforced.delete(ctx)
}
/** Test-only: how many charts are being reconciled. */
export function _enforcedCount() {
return enforced.size
}
/**
* Record that a perspective token was decoded via the STATIC API
* (ApexCharts.perspectives.decode / fromURL). Unlike the instance apply()/save()
* signals, this has no chart to attach to, so it is a global signal that marks
* perspectives as "in use" for any chart that bundles the feature, and it
* re-evaluates all live charts. Deliberately coarse: calling the premium static
* decode API is itself premium usage.
*/
export function markPerspectivesTokenDecoded() {
_perspectivesTokenDecoded = true
reevaluateLicenseAcrossCharts()
}
/** Test-only: reset the process-global premium signals. */
export function _resetPremiumSignals() {
_perspectivesTokenDecoded = false
}
/**
* The premium features this specific chart is USING right now (empty when the
* chart only touches free functionality). "in use", not "bundled".
* @param {any} w
* @param {any} ctx
* @returns {string[]}
*/
export function premiumFeaturesInUse(w, ctx) {
const chart = (w && w.config && w.config.chart) || {}
/** @type {string[]} */
const used = []
// unit (dot-cluster / pictogram): premium CHART TYPE. Unlike the feature
// flags below, "in use" is simply "this chart is a unit chart" - the type is
// the product. It renders fully in trial mode, just watermarked.
if (chart.type === 'unit') used.push('unit')
// storyboard: API-only. bind() sets ctx.storyboard._used; unbind() clears it.
if (ctx.storyboard && ctx.storyboard._used) used.push('storyboard')
// link (crossfilter / linked views): chart.link.enabled (highlight) OR a
// dimension function (filter mode / crossfilter consumer).
const link = chart.link
if (
ctx.linkedViews &&
link &&
(link.enabled === true || typeof link.dimension === 'function')
) {
used.push('link')
}
// ink / measure / context-menu / history: config .enabled flags.
if (ctx.ink && chart.ink && chart.ink.enabled === true) used.push('ink')
if (ctx.measure && chart.measure && chart.measure.enabled === true) {
used.push('measure')
}
if (ctx.contextMenu && chart.contextMenu && chart.contextMenu.enabled === true) {
used.push('context-menu')
}
// perspectives: API-only. apply()/save() set ctx.perspectives._used; the
// static decode/fromURL path sets the process-global signal.
if (ctx.perspectives && (ctx.perspectives._used || _perspectivesTokenDecoded)) {
used.push('perspectives')
}
if (ctx.history && chart.history && chart.history.enabled === true) {
used.push('history')
}
return used
}
/**
* @param {any} w @param {any} ctx
* @returns {boolean} whether this chart is using any gated premium feature
*/
export function premiumFeatureInUse(w, ctx) {
return premiumFeaturesInUse(w, ctx).length > 0
}
/**
* Resolve the effective license key for a chart, most specific first:
* chart.license -> setLicense() singleton -> window.Apex.license -> null
* @param {any} w
* @returns {string | null}
*/
function resolveKey(w) {
const perChart = w && w.config && w.config.chart && w.config.chart.license
if (perChart) return perChart
const singleton = LicenseManager.getKey()
if (singleton) return singleton
const apex = Environment.getApex()
if (apex && apex.license) return apex.license
return null
}
/**
* (Re)create the watermark node and (re)bind a style-tamper observer to it.
* @param {any} ctx @param {HTMLElement} elWrap
*/
function reinstateWatermark(ctx, elWrap) {
const node = Watermark.add(elWrap) // reuse-or-create + apply critical styles
if (!node || typeof MutationObserver === 'undefined') return
if (ctx._wmNodeObserver && ctx._wmObservedNode === node) return
if (ctx._wmNodeObserver) ctx._wmNodeObserver.disconnect()
const nodeObs = new MutationObserver(() => {
const n = Watermark.node(elWrap)
if (!n) return
// Restore critical styles; disconnect + takeRecords so our own write does
// not re-trigger the observer (no feedback loop).
nodeObs.disconnect()
Watermark.applyStyles(n)
nodeObs.takeRecords()
nodeObs.observe(n, { attributes: true, attributeFilter: ['style'] })
})
nodeObs.observe(node, { attributes: true, attributeFilter: ['style'] })
ctx._wmNodeObserver = nodeObs
ctx._wmObservedNode = node
}
/**
* Add the watermark and attach the re-injection observers (childList on the
* wrapper catches node removal; the node observer catches style tampering).
* @param {any} ctx @param {HTMLElement} elWrap
*/
function addWatermark(ctx, elWrap) {
reinstateWatermark(ctx, elWrap)
if (typeof MutationObserver === 'undefined' || ctx._wmWrapObserver) return
const wrapObs = new MutationObserver(() => {
if (!Watermark.node(elWrap)) reinstateWatermark(ctx, elWrap)
})
wrapObs.observe(elWrap, { childList: true })
ctx._wmWrapObserver = wrapObs
}
/**
* Remove the watermark and disconnect its observers. Safe to call when none
* exist; called on a valid license, when premium usage stops, and on destroy().
* @param {any} ctx @param {HTMLElement | null | undefined} [elWrap]
*/
export function teardownWatermark(ctx, elWrap) {
if (ctx._wmWrapObserver) {
ctx._wmWrapObserver.disconnect()
ctx._wmWrapObserver = null
}
if (ctx._wmNodeObserver) {
ctx._wmNodeObserver.disconnect()
ctx._wmNodeObserver = null
}
ctx._wmObservedNode = null
const wrap = elWrap || (ctx.w && ctx.w.dom && ctx.w.dom.elWrap)
if (wrap) Watermark.remove(wrap)
}
/**
* One concise console message per chart, matching the family:
* - a key IS set but invalid/expired/wrong-domain -> console.error the reason
* (skipped for the setLicense singleton, which already errored at set time,
* to avoid duplicate noise)
* - NO key at all -> console.warn naming the feature(s) + pricing link
* @param {any} ctx @param {string | null} key @param {string[]} features
*/
function notifyTrial(ctx, key, features) {
if (ctx._premiumLicenseNotified) return
ctx._premiumLicenseNotified = true
if (!key) {
console.warn(
`[ApexCharts] Premium feature${features.length > 1 ? 's' : ''} in use ` +
`(${features.join(', ')}) without a license. Running in trial mode ` +
`with a watermark. Get a license: ${PRICING_URL}`,
)
return
}
// A key was provided but is not valid. The setLicense singleton already
// console.error'd at set time; only report per-chart / global keys here.
if (key !== LicenseManager.getKey()) {
console.error(`[Apex] ${LicenseManager.validateKey(key).message}`)
}
}
/**
* The single enforcement seam. Called at the end of mount()/fastUpdate(), and
* when an API-driven premium entry point fires. Idempotent; re-evaluated on
* every render so a late setLicense(validKey) + update() clears the watermark.
* @param {any} w @param {any} ctx
*/
export function enforceLicense(w, ctx) {
try {
// SSR / no-DOM: never touch the DOM, never throw. The client hydrate will
// re-evaluate and add the watermark if needed.
if (!Environment.isBrowser()) return
// A destroyed chart is never enforced. destroy() runs teardown hooks that
// call back in here (storyboard.teardown() is one), and the chart's config
// still reads as premium at that point, so without this the chart would be
// re-tracked immediately after being untracked and the enforcer would hold
// its context for the life of the page.
if (w && w.globals && w.globals.isDestroyed) {
enforced.delete(ctx)
return
}
const elWrap = w && w.dom && w.dom.elWrap
// Ordering guard: the DOM cache must be populated before we watermark.
if (!elWrap) return
const features = premiumFeaturesInUse(w, ctx)
// Free-only usage: never watermark, never warn.
if (features.length === 0) {
enforced.delete(ctx)
teardownWatermark(ctx, elWrap)
return
}
// Tracked on premium usage, not on the verdict: a key that looks valid now
// may fail verification a microtask later, and that is exactly the chart
// that needs correcting.
enforced.add(ctx)
const key = resolveKey(w)
if (LicenseManager.isKeyValid(key)) {
teardownWatermark(ctx, elWrap)
return
}
// Premium in use + no valid license -> trial mode (feature still works).
addWatermark(ctx, elWrap)
notifyTrial(ctx, key, features)
} catch {
// Enforcement must never break rendering.
}
}
/**
* Re-run enforcement on every live chart. Used by process-global entry points
* (ApexCharts.crossfilter, static perspectives decode/fromURL) that change
* premium "in use" status without themselves triggering a chart render, and by
* the async signature verdict.
*
* Reaches every chart using a premium feature, whether or not it declares a
* `chart.id`. `Apex._chartInstances` is still walked, because a chart there may
* have turned premium through a path that never ran enforcement, but it is no
* longer the only route.
*/
export function reevaluateLicenseAcrossCharts() {
if (!Environment.isBrowser()) return
const visited = new Set()
const apex = Environment.getApex()
const instances = apex && apex._chartInstances
if (Array.isArray(instances)) {
instances.forEach((entry) => {
const chart = entry && entry.chart
if (chart && chart.w && !chart.w.globals.isDestroyed) {
visited.add(chart)
enforceLicense(chart.w, chart)
}
})
}
// Copied first: enforceLicense mutates `enforced`, and a detached or destroyed
// chart is dropped here so the set cannot pin its subtree in memory.
Array.from(enforced).forEach((ctx) => {
const w = ctx && ctx.w
const elWrap = w && w.dom && w.dom.elWrap
if (!w || w.globals.isDestroyed || !elWrap || elWrap.isConnected === false) {
enforced.delete(ctx)
return
}
if (visited.has(ctx)) return
enforceLicense(w, ctx)
})
}
// Signature verification is asynchronous (crypto.subtle has no sync API) while
// enforcement is decided synchronously during render, so a key is accepted
// provisionally and the verdict lands a microtask later. Without this, a forged
// key would escape the watermark entirely in the ordinary
// setLicense-then-render sequence, because nothing would ask again once the
// chart had painted.
LicenseManager.onChange(reevaluateLicenseAcrossCharts)