import type { Signal } from "@segment/analytics-signals-runtime"; import { loadScript } from "../../lib/load-script"; import { logger } from "../../lib/logger"; import { replaceBaseUrl } from "../../lib/replace-base-url"; import { WorkerBoxAPI, createWorkerBox } from "../../lib/workerbox"; import { ProcessSignal } from "../../types"; import { AnalyticsMethodCalls, AnalyticsRuntime, MethodName, } from "./sandbox-analytics-runtime"; export type { AnalyticsMethodCalls, MethodName }; interface CodeSandbox { run: (fn: string, scope: Record) => Promise; destroy: () => Promise; } class JavascriptSandbox implements CodeSandbox { private workerbox: Promise; constructor() { this.workerbox = createWorkerBox(); } async run(fn: string, scope: Record) { try { const wb = await this.workerbox; await wb.run(fn, scope); } catch (err) { console.error("processSignal() error in sandbox", err, { fn, }); } } async destroy(): Promise { const wb = await this.workerbox; await wb.destroy(); } } export const normalizeEdgeFunctionURL = ( functionHost: string | undefined, edgeFnDownloadURL: string | undefined, ) => { if (functionHost && edgeFnDownloadURL) { replaceBaseUrl(edgeFnDownloadURL, `https://${functionHost}`); } else { return edgeFnDownloadURL; } }; export type SandboxSettingsConfig = { functionHost: string | undefined; edgeFnDownloadURL: string | undefined; edgeFnFetchClient?: typeof fetch; sandboxStrategy: "iframe" | "global"; }; export type IframeSandboxSettingsConfig = Pick< SandboxSettingsConfig, "edgeFnFetchClient" | "edgeFnDownloadURL" >; const consoleWarnProcessSignal = () => console.warn( "processSignal is not defined - have you set up auto-instrumentation on app.segment.com?", ); export class IframeSandboxSettings { processSignal: Promise; constructor(settings: IframeSandboxSettingsConfig) { const fetch = settings.edgeFnFetchClient ?? globalThis.fetch; let processSignalNormalized = Promise.resolve( `globalThis.processSignal = function() {}`, ); if (settings.edgeFnDownloadURL) { processSignalNormalized = fetch(settings.edgeFnDownloadURL!).then((res) => res.text(), ); } else { consoleWarnProcessSignal(); } this.processSignal = processSignalNormalized; } } export interface SignalSandbox { execute( signal: Signal, signals: Signal[], ): Promise; destroy(): void | Promise; } export class WorkerSandbox implements SignalSandbox { settings: IframeSandboxSettings; jsSandbox: CodeSandbox; constructor(settings: IframeSandboxSettings) { this.settings = settings; this.jsSandbox = new JavascriptSandbox(); } async execute( signal: Signal, signalBuffer: Signal[], ): Promise { const analytics = new AnalyticsRuntime(); const scope = { analytics, }; logger.debug("processing signal", { signal, scope, signalBuffer }); const code = [ await this.settings.processSignal, `signals.signalBuffer = ${JSON.stringify(signalBuffer)};`, "try { processSignal(" + JSON.stringify(signal) + ', { analytics, signalBuffer: signals.signalBuffer }); } catch(err) { console.error("Process signal failed.", err); }', ].join("\n"); await this.jsSandbox.run(code, scope); const calls = analytics.getCalls(); return calls; } destroy(): void { void this.jsSandbox.destroy(); } } // ProcessSignal unfortunately uses globals. This should change. // For now, we are setting up the globals between each invocation const processWithGlobalScopeExecutionEnv = ( signal: Signal, signalBuffer: Signal[], ): AnalyticsMethodCalls | undefined => { const g = globalThis as any; const processSignal: ProcessSignal = g["processSignal"]; if (typeof processSignal == "undefined") { consoleWarnProcessSignal(); return undefined; } // processSignal expects a global called `signals` -- of course, there can local variable naming conflict on the client, which is why globals were a bad idea. const analytics = new AnalyticsRuntime(); g["signals"].signalBuffer = signalBuffer; processSignal(signal, { analytics: analytics, signalBuffer, }); return analytics.getCalls(); }; /** * Sandbox that avoids CSP errors, but evaluates everything globally */ interface GlobalScopeSandboxSettings { edgeFnDownloadURL: string; } export class GlobalScopeSandbox implements SignalSandbox { htmlScriptLoaded: Promise; constructor(settings: GlobalScopeSandboxSettings) { logger.debug("Initializing global scope sandbox"); this.htmlScriptLoaded = loadScript(settings.edgeFnDownloadURL); } async execute(signal: Signal, signals: Signal[]) { await this.htmlScriptLoaded; return processWithGlobalScopeExecutionEnv(signal, signals); } destroy(): void {} } export class NoopSandbox implements SignalSandbox { execute(_signal: Signal, _signals: Signal[]) { return Promise.resolve(undefined); } destroy(): void {} }