Skip to content

Commit e5a78be

Browse files
authored
Add builtin playground plugin
1 parent 822d8ce commit e5a78be

File tree

13 files changed

+944
-21
lines changed

13 files changed

+944
-21
lines changed

.github/workflows/deploy-published-releases.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ on:
77

88
env:
99
CDN_URL: https://cdn.croct.io/js/v1/lib/plug.js
10+
PLAYGROUND_ORIGIN: https://play.croct.com/
11+
PLAYGROUND_CONNECT_URL: https://play.croct.com/connect.html
1012

1113
jobs:
1214
deploy:
@@ -63,7 +65,9 @@ jobs:
6365
if: ${{ !github.event.release.prerelease }}
6466
run: |-
6567
rm -rf build
66-
npm run bundle -- --config-cdn-url=${CDN_URL}
68+
npm run bundle -- --config-cdn-url=${CDN_URL} \
69+
--config-playground-origin=${PLAYGROUND_CONNECT_ORIGIN} \
70+
--config-playground-connect-url=${PLAYGROUND_CONNECT_URL}
6771
6872
- name: Authenticate to GCP
6973
if: ${{ !github.event.release.prerelease }}

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"bundle": "rollup -c rollup.config.js"
3434
},
3535
"dependencies": {
36-
"@croct/sdk": "^0.3.2",
36+
"@croct/sdk": "^0.4",
3737
"tslib": "^2.0.1"
3838
},
3939
"devDependencies": {

rollup.config.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ export default args => {
1010
throw new Error('The argument "config-cdn-url" is missing.');
1111
}
1212

13+
if (args['config-playground-origin'] === undefined) {
14+
throw new Error('The argument "config-playground-origin" is missing.');
15+
}
16+
17+
if (args['config-playground-connect-url'] === undefined) {
18+
throw new Error('The argument "config-playground-connect-url" is missing.');
19+
}
20+
1321
return [
1422
{
1523
input: 'src/index.ts',
@@ -31,9 +39,15 @@ export default args => {
3139
replace({
3240
delimiters: ['<@', '@>'],
3341
cdnUrl: args['config-cdn-url'],
42+
playgroundOrigin: args['config-playground-origin'],
43+
playgroundConnectUrl: args['config-playground-connect-url'],
3444

3545
}),
36-
terser(),
46+
terser({
47+
format: {
48+
comments: false,
49+
},
50+
}),
3751
],
3852
},
3953
];

src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export const CDN_URL = '<@cdnUrl@>';
2+
export const PLAYGROUND_ORIGIN = '<@playgroundOrigin@>';
3+
export const PLAYGROUND_CONNECT_URL = '<@playgroundConnectUrl@>';

src/playground.ts

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import {formatCause} from '@croct/sdk/error';
2+
import CidAssigner from '@croct/sdk/cid';
3+
import {ContextFactory, TabContextFactory} from '@croct/sdk/facade/evaluatorFacade';
4+
import {Campaign, EvaluationContext, Page} from '@croct/sdk/evaluator';
5+
import {Plugin, PluginFactory} from './plugin';
6+
import {PLAYGROUND_CONNECT_URL, PLAYGROUND_ORIGIN} from './constants';
7+
import {Logger, SdkEventSubscriber, Tab} from './sdk';
8+
import {TokenProvider} from './sdk/token';
9+
10+
export type Options = {
11+
connectionId?: string,
12+
};
13+
14+
export const factory: PluginFactory<Options> = ({sdk, options}): PlaygroundPlugin => {
15+
return new PlaygroundPlugin({
16+
sdkVersion: sdk.version,
17+
appId: sdk.appId,
18+
connectionId: options.connectionId,
19+
tab: sdk.tab,
20+
storage: sdk.getTabStorage(),
21+
tokenProvider: sdk.tokenStore,
22+
cidAssigner: sdk.cidAssigner,
23+
contextFactory: new TabContextFactory(sdk.tab),
24+
eventSubscriber: sdk.eventManager,
25+
logger: sdk.getLogger(),
26+
});
27+
};
28+
29+
const CONNECTION_PARAMETER = '__cplay';
30+
31+
export type Configuration = {
32+
appId: string,
33+
connectionId?: string,
34+
sdkVersion: string,
35+
tab: Tab,
36+
contextFactory: ContextFactory,
37+
storage: Storage,
38+
eventSubscriber: SdkEventSubscriber,
39+
cidAssigner: CidAssigner,
40+
tokenProvider: TokenProvider,
41+
logger: Logger,
42+
};
43+
44+
export type SyncPayload = {
45+
appId: string,
46+
connectionId: string,
47+
sdkVersion: string,
48+
cid: string,
49+
tabId: string,
50+
token: string|null,
51+
context: {
52+
campaign?: Campaign,
53+
page?: Page,
54+
timezone?: string,
55+
},
56+
};
57+
58+
export class PlaygroundPlugin implements Plugin {
59+
private readonly sdkVersion: string;
60+
61+
private readonly appId: string;
62+
63+
private readonly connectionId?: string;
64+
65+
private readonly tab: Tab;
66+
67+
private readonly contextFactory: ContextFactory;
68+
69+
private readonly storage: Storage;
70+
71+
private readonly eventSubscriber: SdkEventSubscriber;
72+
73+
private readonly cidAssigner: CidAssigner;
74+
75+
private readonly tokenProvider: TokenProvider;
76+
77+
private readonly logger: Logger;
78+
79+
private syncListener: {(): void};
80+
81+
public constructor(configuration: Configuration) {
82+
this.sdkVersion = configuration.sdkVersion;
83+
this.appId = configuration.appId;
84+
this.connectionId = configuration.connectionId;
85+
this.tab = configuration.tab;
86+
this.contextFactory = configuration.contextFactory;
87+
this.storage = configuration.storage;
88+
this.eventSubscriber = configuration.eventSubscriber;
89+
this.cidAssigner = configuration.cidAssigner;
90+
this.tokenProvider = configuration.tokenProvider;
91+
this.logger = configuration.logger;
92+
}
93+
94+
public enable(): Promise<void> | void {
95+
const connectionId = this.resolveConnectionId();
96+
97+
if (connectionId === null) {
98+
return;
99+
}
100+
101+
this.syncListener = (): Promise<void> => {
102+
return this.cidAssigner.assignCid()
103+
.then(cid => {
104+
this.syncToken(connectionId, cid);
105+
})
106+
.catch(error => {
107+
this.logger.warn(`Sync failed: ${formatCause(error)}`);
108+
});
109+
};
110+
111+
this.eventSubscriber.addListener('tokenChanged', this.syncListener);
112+
this.tab.addListener('urlChange', this.syncListener);
113+
114+
return this.syncListener();
115+
}
116+
117+
private resolveConnectionId(): string | null {
118+
if (this.connectionId !== undefined) {
119+
this.logger.debug('Connection ID passed in configuration');
120+
121+
return this.connectionId;
122+
}
123+
124+
const url = new URL(this.tab.url);
125+
126+
let connectionId = url.searchParams.get(CONNECTION_PARAMETER);
127+
128+
if (connectionId === null || connectionId === '') {
129+
this.logger.debug('No connection ID found in URL');
130+
131+
connectionId = this.storage.getItem('connectionId');
132+
133+
this.logger.debug(
134+
connectionId !== null
135+
? 'Previous connection ID found'
136+
: 'No previous connection ID found',
137+
);
138+
139+
return connectionId;
140+
}
141+
142+
this.logger.debug('Connection ID found in URL');
143+
144+
this.storage.setItem('connectionId', connectionId);
145+
146+
return connectionId;
147+
}
148+
149+
public disable(): Promise<void> | void {
150+
if (this.syncListener !== null) {
151+
this.eventSubscriber.removeListener('tokenChanged', this.syncListener);
152+
this.tab.removeListener('urlChange', this.syncListener);
153+
154+
delete this.syncListener;
155+
}
156+
}
157+
158+
private syncToken(connectionId: string, cid: string): void {
159+
const iframe = document.createElement('iframe');
160+
iframe.setAttribute('src', PLAYGROUND_CONNECT_URL);
161+
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin');
162+
iframe.style.visibility = 'hidden';
163+
iframe.style.opacity = '0';
164+
iframe.style.border = '0';
165+
iframe.style.width = '0';
166+
iframe.style.height = '0';
167+
168+
const context = this.createContext();
169+
170+
iframe.onload = (): void => {
171+
if (iframe.contentWindow === null) {
172+
if (document.body.contains(iframe)) {
173+
document.body.removeChild(iframe);
174+
}
175+
176+
this.logger.warn('Sync handshake failed');
177+
178+
return;
179+
}
180+
181+
const listener = (event: MessageEvent): void => {
182+
if (event.origin !== PLAYGROUND_ORIGIN || event.data !== connectionId) {
183+
return;
184+
}
185+
186+
window.removeEventListener('message', listener);
187+
188+
if (document.body.contains(iframe)) {
189+
document.body.removeChild(iframe);
190+
}
191+
192+
this.logger.debug('Sync completed');
193+
};
194+
195+
window.addEventListener('message', listener);
196+
197+
const payload: SyncPayload = {
198+
appId: this.appId,
199+
connectionId: connectionId,
200+
sdkVersion: this.sdkVersion,
201+
tabId: this.tab.id,
202+
cid: cid,
203+
token: this.tokenProvider.getToken()?.toString() ?? null,
204+
context: context,
205+
};
206+
207+
iframe.contentWindow.postMessage(payload, PLAYGROUND_ORIGIN);
208+
209+
this.logger.debug('Waiting for sync acknowledgment...');
210+
};
211+
212+
this.logger.debug('Sync started');
213+
214+
const connect = (): void => {
215+
document.body.appendChild(iframe);
216+
};
217+
218+
if (document.body === null) {
219+
document.addEventListener('DOMContentLoaded', connect);
220+
} else {
221+
connect();
222+
}
223+
}
224+
225+
private createContext(): EvaluationContext {
226+
const {page, campaign, timezone} = this.contextFactory.createContext();
227+
const context: EvaluationContext = {};
228+
229+
if (page !== undefined) {
230+
context.page = page;
231+
}
232+
233+
if (campaign !== undefined) {
234+
context.campaign = campaign;
235+
}
236+
237+
if (timezone !== undefined) {
238+
context.timezone = timezone;
239+
}
240+
241+
return context;
242+
}
243+
}

0 commit comments

Comments
 (0)