Skip to content

Commit 35c86ae

Browse files
committed
rewrite sandbox to just require :blob permission
1 parent 3f15dfa commit 35c86ae

File tree

4 files changed

+215
-22
lines changed

4 files changed

+215
-22
lines changed

packages/signals/signals-example/src/lib/analytics.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
// You only want to instantiate SignalsPlugin in a browser context, otherwise you'll get an error.
33

44
import { AnalyticsBrowser } from '@segment/analytics-next'
5-
import { SignalsPlugin, ProcessSignal } from '@segment/analytics-signals'
5+
import {
6+
SignalsPlugin,
7+
SignalsPluginSettingsConfig,
8+
ProcessSignal,
9+
} from '@segment/analytics-signals'
610

711
export const analytics = new AnalyticsBrowser()
812
if (!process.env.WRITEKEY) {
@@ -29,11 +33,20 @@ const processSignalExample: ProcessSignal = (
2933
}
3034
}
3135

36+
const getQueryParams = () => {
37+
const params = new URLSearchParams()
38+
const sandboxStrategy = params.get('sandboxStrategy')
39+
return {
40+
sandboxStrategy:
41+
sandboxStrategy as SignalsPluginSettingsConfig['sandboxStrategy'],
42+
}
43+
}
3244
const isStage = process.env.STAGE === 'true'
3345

46+
const queryParams = getQueryParams()
3447
const signalsPlugin = new SignalsPlugin({
3548
...(isStage ? { apiHost: 'signals.segment.build/v1' } : {}),
36-
sandboxStrategy: 'global',
49+
sandboxStrategy: queryParams.sandboxStrategy ?? 'iframe',
3750
// processSignal: processSignalExample,
3851
})
3952

packages/signals/signals/src/core/middleware/event-processor/index.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@ import { SignalEventProcessor } from '../../processor/processor'
66
import {
77
normalizeEdgeFunctionURL,
88
GlobalScopeSandbox,
9-
WorkerSandbox,
10-
IframeSandboxSettings,
119
SignalSandbox,
1210
NoopSandbox,
11+
IframeSandbox,
1312
} from '../../processor/sandbox'
1413

1514
export class SignalsEventProcessorSubscriber implements SignalsSubscriber {
@@ -36,12 +35,7 @@ export class SignalsEventProcessorSubscriber implements SignalsSubscriber {
3635
sandboxSettings.processSignal
3736
) {
3837
logger.debug('Initializing sandbox: iframe')
39-
sandbox = new WorkerSandbox(
40-
new IframeSandboxSettings({
41-
processSignal: sandboxSettings.processSignal,
42-
edgeFnDownloadURL: normalizedEdgeFunctionURL,
43-
})
44-
)
38+
sandbox = new IframeSandbox(normalizedEdgeFunctionURL)
4539
} else {
4640
logger.debug('Initializing sandbox: global scope')
4741
sandbox = new GlobalScopeSandbox({

packages/signals/signals/src/core/processor/__tests__/sandbox-settings.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import { IframeSandboxSettings, IframeSandboxSettingsConfig } from '../sandbox'
1+
import {
2+
IframeWorkerSandboxSettings,
3+
IframeSandboxSettingsConfig,
4+
} from '../sandbox'
25

3-
describe(IframeSandboxSettings, () => {
6+
describe(IframeWorkerSandboxSettings, () => {
47
const edgeFnResponseBody = `function processSignal() { console.log('hello world') }`
58
const baseSettings: IframeSandboxSettingsConfig = {
69
processSignal: undefined,
@@ -12,7 +15,7 @@ describe(IframeSandboxSettings, () => {
1215
),
1316
}
1417
test('initializes with provided settings', async () => {
15-
const sandboxSettings = new IframeSandboxSettings({ ...baseSettings })
18+
const sandboxSettings = new IframeWorkerSandboxSettings({ ...baseSettings })
1619
expect(baseSettings.edgeFnFetchClient).toHaveBeenCalledWith(
1720
baseSettings.edgeFnDownloadURL
1821
)
@@ -25,7 +28,7 @@ describe(IframeSandboxSettings, () => {
2528
processSignal: undefined,
2629
edgeFnDownloadURL: 'https://foo.com/download',
2730
}
28-
new IframeSandboxSettings(settings)
31+
new IframeWorkerSandboxSettings(settings)
2932
expect(baseSettings.edgeFnFetchClient).toHaveBeenCalledWith(
3033
'https://foo.com/download'
3134
)
@@ -40,7 +43,7 @@ describe(IframeSandboxSettings, () => {
4043
processSignal: undefined,
4144
edgeFnDownloadURL: undefined,
4245
}
43-
const sandboxSettings = new IframeSandboxSettings(settings)
46+
const sandboxSettings = new IframeWorkerSandboxSettings(settings)
4447
expect(await sandboxSettings.processSignal).toMatchInlineSnapshot(
4548
`"globalThis.processSignal = function() {}"`
4649
)

packages/signals/signals/src/core/processor/sandbox.ts

Lines changed: 190 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -180,12 +180,12 @@ export type IframeSandboxSettingsConfig = Pick<
180180
'processSignal' | 'edgeFnFetchClient' | 'edgeFnDownloadURL'
181181
>
182182

183-
const consoleWarnProcessSignal = () =>
184-
console.warn(
185-
'processSignal is not defined - have you set up auto-instrumentation on app.segment.com?'
186-
)
183+
const PROCESS_SIGNAL_UNDEFINED =
184+
'processSignal is not defined - have you set up auto-instrumentation on app.segment.com?'
187185

188-
export class IframeSandboxSettings {
186+
const consoleWarnProcessSignal = () => console.warn(PROCESS_SIGNAL_UNDEFINED)
187+
188+
export class IframeWorkerSandboxSettings {
189189
/**
190190
* Should look like:
191191
* ```js
@@ -227,10 +227,10 @@ export interface SignalSandbox {
227227
}
228228

229229
export class WorkerSandbox implements SignalSandbox {
230-
settings: IframeSandboxSettings
230+
settings: IframeWorkerSandboxSettings
231231
jsSandbox: CodeSandbox
232232

233-
constructor(settings: IframeSandboxSettings) {
233+
constructor(settings: IframeWorkerSandboxSettings) {
234234
this.settings = settings
235235
this.jsSandbox = new JavascriptSandbox()
236236
}
@@ -343,3 +343,186 @@ export class NoopSandbox implements SignalSandbox {
343343
}
344344
destroy(): void {}
345345
}
346+
347+
/**
348+
* window.addEventListener('message', async (event) => {
349+
const { type, payload } = event.data
350+
if (type === 'execute') {
351+
try {
352+
const { signal, signals, analytics, SignalType, EventType, NavigationAction } = payload
353+
await processSignal(signal, { analytics, signals, SignalType, EventType, NavigationAction })
354+
event.source.postMessage({ type: 'result', payload: analytics.getCalls() }, '*')
355+
} catch (err) {
356+
event.source.postMessage({ type: 'error', error: err.message }, '*')
357+
}
358+
}
359+
})
360+
361+
window.parent.postMessage('iframe_ready', '*')
362+
*/
363+
364+
const noramizeMethodCallsWithArgResolver = (
365+
methodCalls: AnalyticsMethodCalls
366+
) => {
367+
const normalizedRuntime = new AnalyticsRuntime()
368+
Object.entries(methodCalls).forEach(([methodName, calls]) => {
369+
calls.forEach((args) => {
370+
// @ts-ignore
371+
normalizedRuntime[methodName](...args)
372+
})
373+
})
374+
return normalizedRuntime.getCalls()
375+
}
376+
export class IframeSandbox implements SignalSandbox {
377+
private iframe: HTMLIFrameElement
378+
private iframeReady: Promise<void>
379+
private _resolveReady!: () => void
380+
edgeFnUrl: string
381+
382+
constructor(edgeFnUrl: string) {
383+
this.edgeFnUrl = edgeFnUrl
384+
this.iframe = document.createElement('iframe')
385+
this.iframe.id = 'segment-signals-sandbox'
386+
this.iframe.style.display = 'none'
387+
this.iframe.src = 'about:blank'
388+
document.body.appendChild(this.iframe)
389+
this.iframeReady = new Promise((res) => {
390+
this._resolveReady = res
391+
})
392+
393+
void window.addEventListener('message', (e) => {
394+
if (e.source === this.iframe.contentWindow && e.data === 'iframe_ready') {
395+
this.iframe.contentWindow!.postMessage(
396+
{
397+
type: 'init',
398+
},
399+
'*'
400+
)
401+
this._resolveReady()
402+
}
403+
})
404+
405+
const doc = this.iframe.contentDocument!
406+
doc.open()
407+
doc.write(
408+
`<!DOCTYPE html><html><head><script id="edge-fn" src=${this.edgeFnUrl}></script></head><body></body></html>`
409+
)
410+
doc.close()
411+
412+
// External signal processor script
413+
// Inject runtime via Blob (CSP-safe)
414+
const runtimeJs = `
415+
const signalsScript = document.getElementById('edge-fn')
416+
signalsScript.onload = () => {
417+
window.parent.postMessage('iframe_ready', '*')
418+
}
419+
420+
class AnalyticsRuntimeProxy {
421+
constructor() {
422+
this.calls = new Map();
423+
}
424+
getFormattedCalls() {
425+
return Object.fromEntries(this.calls); // call in {track: [args]} format
426+
}
427+
createProxy() {
428+
return new Proxy({}, {
429+
get: (_, methodName) => {
430+
return (...args) => {
431+
if (!this.calls.has(methodName)) {
432+
this.calls.set(methodName, []);
433+
}
434+
this.calls.get(methodName).push(args);
435+
};
436+
},
437+
});
438+
}
439+
}
440+
441+
442+
// expose the signals global
443+
${getRuntimeCode()}
444+
445+
window.addEventListener('message', async (event) => {
446+
const { type, payload } = event.data;
447+
448+
449+
if (type === 'execute') {
450+
try {
451+
const analyticsProxy = new AnalyticsRuntimeProxy();
452+
window.analytics = analyticsProxy.createProxy();
453+
if (!payload.signal) {
454+
throw new Error('invariant: no signal found')
455+
}
456+
if (!payload.signalBuffer) {
457+
throw new Error('invariant: no signalBuffer found')
458+
}
459+
if (!payload.constants) {
460+
throw new Error('invariant: no constants found')
461+
}
462+
if (typeof processSignal === 'undefined') {
463+
throw new Error('processSignal is undefined')
464+
}
465+
466+
const signalBuffer = payload.signalBuffer
467+
const signal = payload.signal
468+
const constants = payload.constants
469+
Object.entries(constants).forEach(([key, value]) => { // expose constants as globals
470+
window[key] = value;
471+
});
472+
window.signals.signalBuffer = signalBuffer; // signals is exposed as part of get runtimeCode
473+
window.processSignal(signal, { signals, constants })
474+
event.source.postMessage({ type: 'execution_result', payload: analyticsProxy.getFormattedCalls() }, '*');
475+
} catch(err) {
476+
event.source.postMessage({ type: 'execution_error', error: err }, '*');
477+
}
478+
}
479+
});
480+
481+
482+
`
483+
const blob = new Blob([runtimeJs], { type: 'application/javascript' })
484+
const runtimeScript = doc.createElement('script')
485+
runtimeScript.src = URL.createObjectURL(blob)
486+
487+
doc.head.appendChild(runtimeScript)
488+
}
489+
490+
async execute(
491+
signal: Signal,
492+
signals: Signal[]
493+
): Promise<AnalyticsMethodCalls> {
494+
await this.iframeReady
495+
496+
return new Promise((resolve, reject) => {
497+
const handler = (e: MessageEvent) => {
498+
if (e.source !== this.iframe.contentWindow) return
499+
if (e.data?.type === 'execution_result') {
500+
window.removeEventListener('message', handler)
501+
resolve(noramizeMethodCallsWithArgResolver(e.data.payload))
502+
}
503+
if (e.data?.type === 'execution_error') {
504+
window.removeEventListener('message', handler)
505+
reject(e.data.error)
506+
}
507+
}
508+
509+
window.addEventListener('message', handler)
510+
511+
this.iframe.contentWindow!.postMessage(
512+
{
513+
type: 'execute',
514+
payload: {
515+
signal,
516+
signalBuffer: signals,
517+
constants: WebRuntimeConstants,
518+
},
519+
},
520+
'*'
521+
)
522+
})
523+
}
524+
525+
destroy() {
526+
this.iframe.remove()
527+
}
528+
}

0 commit comments

Comments
 (0)