Skip to content

Commit a347bce

Browse files
committed
add experimental global scope executor that is impractical because of the reliance on globals
1 parent f2c2b76 commit a347bce

File tree

6 files changed

+225
-45
lines changed

6 files changed

+225
-45
lines changed

packages/signals/signals-example/public/index.html

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,17 @@
55
<meta charset="UTF-8">
66
<meta name="viewport" content="width=device-width, initial-scale=1.0">
77
<title>React TypeScript App</title>
8+
<!---
9+
10+
1. Requres 'unsafe-inline'
11+
- Refused to execute inline script because it violates the following Content Security Policy directive: [directive] Either the 'unsafe-inline' keyword, a hash ('sha256-XDT/UwTV/dYYXFad1cmKD+q1zZNK8KGVRYvIdvkmo7I='), or a nonce ('nonce-...') is required to enable inline execution.
12+
2. Requires 'unsafe-eval'
13+
processSignal() error in sandbox Error: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive
14+
-->
15+
<meta http-equiv="Content-Security-Policy" content="script-src 'self' https://*.segment.com https://*.googletagmanager.com blob:">
816
</head>
9-
1017
<body>
1118
<div id="root"></div>
1219
</body>
1320

14-
</html>
21+
</html>

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

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,48 @@ import { Signal } from '@segment/analytics-signals-runtime'
22
import { SignalBuffer } from '../../buffer'
33
import { SignalsSubscriber, SignalsMiddlewareContext } from '../../emitter'
44
import { SignalEventProcessor } from '../../processor/processor'
5-
import { Sandbox, SandboxSettings } from '../../processor/sandbox'
5+
import {
6+
normalizeEdgeFunctionURL,
7+
GlobalScopeSandbox,
8+
WorkerSandbox,
9+
WorkerSandboxSettings,
10+
SignalSandbox,
11+
NoopSandbox,
12+
} from '../../processor/sandbox'
13+
14+
const GLOBAL_SCOPE_SANDBOX = true
615

716
export class SignalsEventProcessorSubscriber implements SignalsSubscriber {
817
processor!: SignalEventProcessor
918
buffer!: SignalBuffer
1019
load(ctx: SignalsMiddlewareContext) {
1120
this.buffer = ctx.buffer
12-
this.processor = new SignalEventProcessor(
13-
ctx.analyticsInstance,
14-
new Sandbox(new SandboxSettings(ctx.unstableGlobalSettings.sandbox))
21+
const sandboxSettings = ctx.unstableGlobalSettings.sandbox
22+
const normalizedEdgeFunctionURL = normalizeEdgeFunctionURL(
23+
sandboxSettings.functionHost,
24+
sandboxSettings.edgeFnDownloadURL
1525
)
26+
27+
let sandbox: SignalSandbox
28+
if (!normalizedEdgeFunctionURL) {
29+
console.warn(
30+
`No processSignal function found. Have you written a processSignal function on app.segment.com?`
31+
)
32+
sandbox = new NoopSandbox()
33+
} else if (!GLOBAL_SCOPE_SANDBOX) {
34+
sandbox = new WorkerSandbox(
35+
new WorkerSandboxSettings({
36+
processSignal: sandboxSettings.processSignal,
37+
edgeFnDownloadURL: normalizedEdgeFunctionURL,
38+
})
39+
)
40+
} else {
41+
sandbox = new GlobalScopeSandbox({
42+
edgeFnDownloadURL: normalizedEdgeFunctionURL,
43+
})
44+
}
45+
46+
this.processor = new SignalEventProcessor(ctx.analyticsInstance, sandbox)
1647
}
1748
async process(signal: Signal) {
1849
return this.processor.process(signal, await this.buffer.getAll())

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { SandboxSettings, SandboxSettingsConfig } from '../sandbox'
1+
import { WorkerSandboxSettings, SandboxSettingsConfig } from '../sandbox'
22

3-
describe(SandboxSettings, () => {
3+
describe(WorkerSandboxSettings, () => {
44
const edgeFnResponseBody = `function processSignal() { console.log('hello world') }`
55
const baseSettings: SandboxSettingsConfig = {
66
functionHost: undefined,
@@ -13,7 +13,7 @@ describe(SandboxSettings, () => {
1313
),
1414
}
1515
test('initializes with provided settings', async () => {
16-
const sandboxSettings = new SandboxSettings({ ...baseSettings })
16+
const sandboxSettings = new WorkerSandboxSettings({ ...baseSettings })
1717
expect(baseSettings.edgeFnFetchClient).toHaveBeenCalledWith(
1818
baseSettings.edgeFnDownloadURL
1919
)
@@ -27,7 +27,7 @@ describe(SandboxSettings, () => {
2727
functionHost: 'newHost.com',
2828
edgeFnDownloadURL: 'https://original.com/download',
2929
}
30-
new SandboxSettings(settings)
30+
new WorkerSandboxSettings(settings)
3131
expect(baseSettings.edgeFnFetchClient).toHaveBeenCalledWith(
3232
'https://newHost.com/download'
3333
)
@@ -42,7 +42,7 @@ describe(SandboxSettings, () => {
4242
processSignal: undefined,
4343
edgeFnDownloadURL: undefined,
4444
}
45-
const sandboxSettings = new SandboxSettings(settings)
45+
const sandboxSettings = new WorkerSandboxSettings(settings)
4646
expect(await sandboxSettings.processSignal).toEqual(
4747
'globalThis.processSignal = function processSignal() {}'
4848
)

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

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
import { logger } from '../../lib/logger'
22
import { Signal } from '@segment/analytics-signals-runtime'
33
import { AnyAnalytics } from '../../types'
4-
import { AnalyticsMethodCalls, MethodName, Sandbox } from './sandbox'
4+
import { AnalyticsMethodCalls, MethodName, SignalSandbox } from './sandbox'
55

66
export class SignalEventProcessor {
7-
private sandbox: Sandbox
8-
private analytics: AnyAnalytics
9-
constructor(analytics: AnyAnalytics, sandbox: Sandbox) {
7+
analytics: AnyAnalytics
8+
sandbox: SignalSandbox
9+
constructor(analytics: AnyAnalytics, sandbox: SignalSandbox) {
1010
this.analytics = analytics
1111
this.sandbox = sandbox
1212
}
1313

1414
async process(signal: Signal, signals: Signal[]) {
15-
let analyticsMethodCalls: AnalyticsMethodCalls
15+
await this.sandbox.isLoaded()
16+
let analyticsMethodCalls: AnalyticsMethodCalls | undefined
1617
try {
17-
analyticsMethodCalls = await this.sandbox.process(signal, signals)
18+
analyticsMethodCalls = await this.sandbox.execute(signal, signals)
1819
} catch (err) {
1920
// in practice, we should never hit this error, but if we do, we should log it.
2021
console.error('Error processing signal', { signal, signals }, err)
@@ -34,6 +35,6 @@ export class SignalEventProcessor {
3435
}
3536

3637
cleanup() {
37-
return this.sandbox.jsSandbox.destroy()
38+
return this.sandbox.destroy()
3839
}
3940
}

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

Lines changed: 102 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ import { createWorkerBox, WorkerBoxAPI } from '../../lib/workerbox'
33
import { resolvers } from './arg-resolvers'
44
import { AnalyticsRuntimePublicApi } from '../../types'
55
import { replaceBaseUrl } from '../../lib/replace-base-url'
6-
import { Signal } from '@segment/analytics-signals-runtime'
6+
import {
7+
Signal,
8+
WebRuntimeConstants,
9+
WebSignalsRuntime,
10+
} from '@segment/analytics-signals-runtime'
711
import { getRuntimeCode } from '@segment/analytics-signals-runtime'
812
import { polyfills } from './polyfills'
13+
import { loadScript } from '../../lib/load-script'
914

1015
export type MethodName =
1116
| 'page'
@@ -151,14 +156,30 @@ class JavascriptSandbox implements CodeSandbox {
151156
}
152157
}
153158

159+
export const normalizeEdgeFunctionURL = (
160+
functionHost: string | undefined,
161+
edgeFnDownloadURL: string | undefined
162+
) => {
163+
if (functionHost && edgeFnDownloadURL) {
164+
replaceBaseUrl(edgeFnDownloadURL, `https://${functionHost}`)
165+
} else {
166+
return edgeFnDownloadURL
167+
}
168+
}
169+
154170
export type SandboxSettingsConfig = {
155171
functionHost: string | undefined
156172
processSignal: string | undefined
157173
edgeFnDownloadURL: string | undefined
158174
edgeFnFetchClient?: typeof fetch
159175
}
160176

161-
export class SandboxSettings {
177+
export type WorkerboxSettingsConfig = Pick<
178+
SandboxSettingsConfig,
179+
'processSignal' | 'edgeFnFetchClient' | 'edgeFnDownloadURL'
180+
>
181+
182+
export class WorkerSandboxSettings {
162183
/**
163184
* Should look like:
164185
* ```js
@@ -168,48 +189,42 @@ export class SandboxSettings {
168189
* ```
169190
*/
170191
processSignal: Promise<string>
171-
constructor(settings: SandboxSettingsConfig) {
172-
const edgeFnDownloadURLNormalized =
173-
settings.functionHost && settings.edgeFnDownloadURL
174-
? replaceBaseUrl(
175-
settings.edgeFnDownloadURL,
176-
`https://${settings.functionHost}`
177-
)
178-
: settings.edgeFnDownloadURL
179-
180-
if (!edgeFnDownloadURLNormalized && !settings.processSignal) {
181-
// user may be onboarding and not have written a signal -- so do a noop so we can collect signals
182-
this.processSignal = Promise.resolve(
183-
`globalThis.processSignal = function processSignal() {}`
184-
)
185-
console.warn(
186-
`No processSignal function found. Have you written a processSignal function on app.segment.com?`
187-
)
188-
return
189-
}
190-
192+
constructor(settings: WorkerboxSettingsConfig) {
191193
const fetch = settings.edgeFnFetchClient ?? globalThis.fetch
192194

193195
const processSignalNormalized = settings.processSignal
194196
? Promise.resolve(settings.processSignal).then(
195197
(str) => `globalThis.processSignal = ${str}`
196198
)
197-
: fetch(edgeFnDownloadURLNormalized!).then((res) => res.text())
199+
: fetch(settings.edgeFnDownloadURL!).then((res) => res.text())
198200

199201
this.processSignal = processSignalNormalized
200202
}
201203
}
202204

203-
export class Sandbox {
204-
settings: SandboxSettings
205+
export interface SignalSandbox {
206+
isLoaded(): Promise<void>
207+
execute(
208+
signal: Signal,
209+
signals: Signal[]
210+
): Promise<AnalyticsMethodCalls | undefined>
211+
destroy(): void | Promise<void>
212+
}
213+
214+
export class WorkerSandbox implements SignalSandbox {
215+
settings: WorkerSandboxSettings
205216
jsSandbox: CodeSandbox
206217

207-
constructor(settings: SandboxSettings) {
218+
constructor(settings: WorkerSandboxSettings) {
208219
this.settings = settings
209220
this.jsSandbox = new JavascriptSandbox()
210221
}
211222

212-
async process(
223+
isLoaded(): Promise<any> {
224+
return Promise.resolve() // TODO
225+
}
226+
227+
async execute(
213228
signal: Signal,
214229
signals: Signal[]
215230
): Promise<AnalyticsMethodCalls> {
@@ -232,4 +247,64 @@ export class Sandbox {
232247
const calls = analytics.getCalls()
233248
return calls
234249
}
250+
destroy(): void {
251+
void this.jsSandbox.destroy()
252+
}
253+
}
254+
255+
// This is not ideal -- but processSignal currently depends on
256+
257+
const processWithGlobals = (
258+
signalBuffer: Signal[]
259+
): AnalyticsMethodCalls | undefined => {
260+
const g = globalThis as any
261+
// Load all constants into the global scope
262+
Object.entries(WebRuntimeConstants).forEach(([key, value]) => {
263+
g[key] = value
264+
})
265+
266+
// 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.
267+
g['signals'] = new WebSignalsRuntime(signalBuffer)
268+
269+
// expect analytics to be instantiated -- this will conflict in the global scope TODO
270+
g['analytics'] = new AnalyticsRuntime()
271+
272+
// another possible namespace conflict?
273+
// @ts-ignore
274+
if (typeof processSignal != 'undefined') {
275+
g['processSignal'](signalBuffer[0])
276+
} else {
277+
console.warn('no processSignal function is defined in the global scope')
278+
}
279+
280+
return g['analytics'].getCalls()
281+
}
282+
283+
interface GlobalScopeSandboxSettings {
284+
edgeFnDownloadURL: string
285+
}
286+
export class GlobalScopeSandbox implements SignalSandbox {
287+
script: Promise<HTMLScriptElement>
288+
async isLoaded(): Promise<void> {
289+
await this.script
290+
}
291+
constructor(settings: GlobalScopeSandboxSettings) {
292+
this.script = loadScript(settings.edgeFnDownloadURL)
293+
}
294+
295+
// eslint-disable-next-line @typescript-eslint/require-await
296+
async execute(_signal: Signal, signals: Signal[]) {
297+
return processWithGlobals(signals)
298+
}
299+
destroy(): void {}
300+
}
301+
302+
export class NoopSandbox implements SignalSandbox {
303+
async isLoaded(): Promise<void> {}
304+
execute(_signal: Signal, _signals: Signal[]) {
305+
return Promise.resolve(undefined)
306+
}
307+
destroy(): void | Promise<void> {
308+
return
309+
}
235310
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
function findScript(src: string): HTMLScriptElement | undefined {
2+
const scripts = Array.prototype.slice.call(
3+
window.document.querySelectorAll('script')
4+
)
5+
return scripts.find((s) => s.src === src)
6+
}
7+
8+
/**
9+
* Load a script from a URL and append it to the document head
10+
*/
11+
export function loadScript(
12+
src: string,
13+
attributes?: Record<string, string>
14+
): Promise<HTMLScriptElement> {
15+
const found = findScript(src)
16+
17+
if (found !== undefined) {
18+
const status = found?.getAttribute('status')
19+
20+
if (status === 'loaded') {
21+
return Promise.resolve(found)
22+
}
23+
24+
if (status === 'loading') {
25+
return new Promise((resolve, reject) => {
26+
found.addEventListener('load', () => resolve(found))
27+
found.addEventListener('error', (err) => reject(err))
28+
})
29+
}
30+
}
31+
32+
return new Promise((resolve, reject) => {
33+
const script = window.document.createElement('script')
34+
35+
script.type = 'text/javascript'
36+
script.src = src
37+
script.async = true
38+
39+
script.setAttribute('status', 'loading')
40+
for (const [k, v] of Object.entries(attributes ?? {})) {
41+
script.setAttribute(k, v)
42+
}
43+
44+
script.onload = (): void => {
45+
script.onerror = script.onload = null
46+
script.setAttribute('status', 'loaded')
47+
resolve(script)
48+
}
49+
50+
script.onerror = (): void => {
51+
script.onerror = script.onload = null
52+
script.setAttribute('status', 'error')
53+
reject(new Error(`Failed to load ${src}`))
54+
}
55+
56+
const firstExistingScript = window.document.querySelector('script')
57+
if (!firstExistingScript) {
58+
window.document.head.appendChild(script)
59+
} else {
60+
firstExistingScript.parentElement?.insertBefore(
61+
script,
62+
firstExistingScript
63+
)
64+
}
65+
})
66+
}

0 commit comments

Comments
 (0)