@ht-sdks/events-sdk-js-browser 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -4
- package/dist/cjs/browser/index.js +76 -42
- package/dist/cjs/browser/index.js.map +1 -1
- package/dist/cjs/core/analytics/index.js +1 -1
- package/dist/cjs/core/analytics/index.js.map +1 -1
- package/dist/cjs/core/http-cookies/index.js +174 -0
- package/dist/cjs/core/http-cookies/index.js.map +1 -0
- package/dist/cjs/core/user/index.js +32 -6
- package/dist/cjs/core/user/index.js.map +1 -1
- package/dist/cjs/core/user/tld.js +7 -3
- package/dist/cjs/core/user/tld.js.map +1 -1
- package/dist/cjs/generated/version.js +1 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/node/index.js +1 -1
- package/dist/cjs/node/index.js.map +1 -1
- package/dist/cjs/plugins/analytics-node/index.js +2 -2
- package/dist/cjs/plugins/analytics-node/index.js.map +1 -1
- package/dist/cjs/plugins/destinations/destination.js +81 -0
- package/dist/cjs/plugins/destinations/destination.js.map +1 -0
- package/dist/cjs/plugins/destinations/google-tag-manager.js +47 -0
- package/dist/cjs/plugins/destinations/google-tag-manager.js.map +1 -0
- package/dist/cjs/plugins/destinations/index.js +22 -0
- package/dist/cjs/plugins/destinations/index.js.map +1 -0
- package/dist/cjs/plugins/destinations/types.js +3 -0
- package/dist/cjs/plugins/destinations/types.js.map +1 -0
- package/dist/pkg/browser/index.js +76 -42
- package/dist/pkg/browser/index.js.map +1 -1
- package/dist/pkg/core/analytics/index.js +1 -1
- package/dist/pkg/core/analytics/index.js.map +1 -1
- package/dist/pkg/core/http-cookies/index.js +171 -0
- package/dist/pkg/core/http-cookies/index.js.map +1 -0
- package/dist/pkg/core/user/index.js +32 -6
- package/dist/pkg/core/user/index.js.map +1 -1
- package/dist/pkg/core/user/tld.js +7 -3
- package/dist/pkg/core/user/tld.js.map +1 -1
- package/dist/pkg/generated/version.js +1 -1
- package/dist/pkg/index.js +1 -0
- package/dist/pkg/index.js.map +1 -1
- package/dist/pkg/node/index.js +1 -1
- package/dist/pkg/node/index.js.map +1 -1
- package/dist/pkg/plugins/analytics-node/index.js +2 -2
- package/dist/pkg/plugins/analytics-node/index.js.map +1 -1
- package/dist/pkg/plugins/destinations/destination.js +78 -0
- package/dist/pkg/plugins/destinations/destination.js.map +1 -0
- package/dist/pkg/plugins/destinations/google-tag-manager.js +45 -0
- package/dist/pkg/plugins/destinations/google-tag-manager.js.map +1 -0
- package/dist/pkg/plugins/destinations/index.js +18 -0
- package/dist/pkg/plugins/destinations/index.js.map +1 -0
- package/dist/pkg/plugins/destinations/types.js +2 -0
- package/dist/pkg/plugins/destinations/types.js.map +1 -0
- package/dist/types/browser/index.d.ts.map +1 -1
- package/dist/types/core/analytics/index.d.ts +14 -0
- package/dist/types/core/analytics/index.d.ts.map +1 -1
- package/dist/types/core/buffer/index.d.ts +1 -1
- package/dist/types/core/http-cookies/index.d.ts +57 -0
- package/dist/types/core/http-cookies/index.d.ts.map +1 -0
- package/dist/types/core/user/index.d.ts +5 -0
- package/dist/types/core/user/index.d.ts.map +1 -1
- package/dist/types/core/user/tld.d.ts.map +1 -1
- package/dist/types/generated/version.d.ts +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/plugins/destinations/destination.d.ts +32 -0
- package/dist/types/plugins/destinations/destination.d.ts.map +1 -0
- package/dist/types/plugins/destinations/google-tag-manager.d.ts +30 -0
- package/dist/types/plugins/destinations/google-tag-manager.d.ts.map +1 -0
- package/dist/types/plugins/destinations/index.d.ts +6 -0
- package/dist/types/plugins/destinations/index.d.ts.map +1 -0
- package/dist/types/plugins/destinations/types.d.ts +5 -0
- package/dist/types/plugins/destinations/types.d.ts.map +1 -0
- package/dist/umd/events.min.js +1 -1
- package/dist/umd/events.min.js.map +1 -1
- package/dist/umd/google-tag-manager.bundle.c27daab560c3298c04ca.js +2 -0
- package/dist/umd/google-tag-manager.bundle.c27daab560c3298c04ca.js.map +1 -0
- package/dist/umd/index.js +1 -1
- package/dist/umd/index.js.map +1 -1
- package/package.json +14 -19
- package/src/browser/index.ts +20 -0
- package/src/core/analytics/index.ts +20 -0
- package/src/core/http-cookies/README.md +97 -0
- package/src/core/http-cookies/index.ts +165 -0
- package/src/core/http-cookies/server-examples/node-aws-lambda.md +167 -0
- package/src/core/http-cookies/server-examples/node-express-js.md +103 -0
- package/src/core/http-cookies/server-examples/node-next-js.md +75 -0
- package/src/core/user/index.ts +42 -4
- package/src/core/user/tld.ts +8 -4
- package/src/generated/version.ts +1 -1
- package/src/index.ts +1 -0
- package/src/node/index.ts +1 -1
- package/src/plugins/analytics-node/index.ts +2 -2
- package/src/plugins/destinations/destination.ts +85 -0
- package/src/plugins/destinations/google-tag-manager.ts +96 -0
- package/src/plugins/destinations/index.ts +19 -0
- package/src/plugins/destinations/types.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ht-sdks/events-sdk-js-browser",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/ht-sdks/events-sdk-js-mono",
|
|
@@ -24,28 +24,24 @@
|
|
|
24
24
|
],
|
|
25
25
|
"sideEffects": false,
|
|
26
26
|
"scripts": {
|
|
27
|
-
".": "yarn run -T turbo run --filter=@ht-sdks/events-sdk-js-browser...",
|
|
28
27
|
"build-prep": "sh scripts/build-prep.sh",
|
|
29
|
-
"version": "
|
|
28
|
+
"version": "npm run build-prep && git add src/generated/version.ts",
|
|
30
29
|
"umd": "webpack",
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"candidate:cdn": "yarn . build && NODE_ENV=production PATH_PREFIX=browser/candidate bash scripts/release.sh",
|
|
38
|
-
"release:cdn": "yarn . build && NODE_ENV=production PATH_PREFIX=browser/release bash scripts/release.sh",
|
|
39
|
-
"pkg": "yarn tsc -p tsconfig.build.json",
|
|
40
|
-
"cjs": "yarn tsc -p tsconfig.build.json --outDir ./dist/cjs --module commonjs",
|
|
30
|
+
"watch": "concurrently 'NODE_ENV=production WATCH=true npm run umd -- --watch' 'npm run pkg -- --watch'",
|
|
31
|
+
"build": "npm run clean && npm run build-prep && concurrently 'NODE_ENV=production npm run umd' 'npm:pkg' 'npm:cjs'",
|
|
32
|
+
"candidate:cdn": "turbo run build && NODE_ENV=production PATH_PREFIX=browser/candidate bash scripts/release.sh",
|
|
33
|
+
"release:cdn": "turbo run build && NODE_ENV=production PATH_PREFIX=browser/release bash scripts/release.sh",
|
|
34
|
+
"pkg": "tsc -p tsconfig.build.json",
|
|
35
|
+
"cjs": "tsc -p tsconfig.build.json --outDir ./dist/cjs --module commonjs",
|
|
41
36
|
"clean": "rm -rf dist",
|
|
42
|
-
"lint": "
|
|
43
|
-
"test": "
|
|
37
|
+
"lint": "concurrently 'eslint .' 'tsc --noEmit'",
|
|
38
|
+
"test": "jest",
|
|
39
|
+
"size-limit": "size-limit"
|
|
44
40
|
},
|
|
45
41
|
"size-limit": [
|
|
46
42
|
{
|
|
47
43
|
"path": "dist/umd/index.js",
|
|
48
|
-
"limit": "
|
|
44
|
+
"limit": "37 KB"
|
|
49
45
|
}
|
|
50
46
|
],
|
|
51
47
|
"dependencies": {
|
|
@@ -86,7 +82,7 @@
|
|
|
86
82
|
"jest-dev-server": "^6.0.3",
|
|
87
83
|
"jest-environment-jsdom": "^28.1.1",
|
|
88
84
|
"jquery": "^3.5.1",
|
|
89
|
-
"jsdom": "^
|
|
85
|
+
"jsdom": "^20.0.0",
|
|
90
86
|
"lighthouse": "^9.6.3",
|
|
91
87
|
"log-update": "^4.0.0",
|
|
92
88
|
"micro-memoize": "^4.0.9",
|
|
@@ -101,6 +97,5 @@
|
|
|
101
97
|
"webpack": "^5.76.0",
|
|
102
98
|
"webpack-bundle-analyzer": "^4.4.2",
|
|
103
99
|
"webpack-cli": "^4.8.0"
|
|
104
|
-
}
|
|
105
|
-
"packageManager": "yarn@3.4.1"
|
|
100
|
+
}
|
|
106
101
|
}
|
package/src/browser/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { getCDN, setGlobalCDNUrl } from '../lib/parse-cdn'
|
|
|
4
4
|
import { fetch } from '../lib/fetch'
|
|
5
5
|
import { Analytics, AnalyticsSettings, InitOptions } from '../core/analytics'
|
|
6
6
|
import { Context } from '../core/context'
|
|
7
|
+
import { HTTPCookieService } from '../core/http-cookies'
|
|
7
8
|
import { Plan } from '../core/events'
|
|
8
9
|
import { Plugin } from '../core/plugin'
|
|
9
10
|
import { MetricsOptions } from '../core/stats/remote-metrics'
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
import { ClassicIntegrationSource } from '../plugins/ajs-destination/types'
|
|
30
31
|
import { attachInspector } from '../core/inspector'
|
|
31
32
|
import { setGlobalAnalyticsKey } from '../lib/global-analytics-helper'
|
|
33
|
+
import { createDestination } from '../plugins/destinations'
|
|
32
34
|
|
|
33
35
|
export interface LegacyIntegrationConfiguration {
|
|
34
36
|
/* @deprecated - This does not indicate browser types anymore */
|
|
@@ -269,6 +271,18 @@ async function registerPlugins(
|
|
|
269
271
|
)
|
|
270
272
|
}
|
|
271
273
|
|
|
274
|
+
// destination plugins
|
|
275
|
+
await Promise.allSettled(
|
|
276
|
+
Object.entries(options.destinations ?? {}).map(async ([name, settings]) => {
|
|
277
|
+
const plugin = await createDestination(name, settings)
|
|
278
|
+
if (plugin) {
|
|
279
|
+
toRegister.push(plugin)
|
|
280
|
+
} else {
|
|
281
|
+
console.warn(`failed to load destination plugin: ${name}`)
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
)
|
|
285
|
+
|
|
272
286
|
const ctx = await analytics.register(...toRegister)
|
|
273
287
|
|
|
274
288
|
if (
|
|
@@ -359,6 +373,12 @@ async function loadAnalytics(
|
|
|
359
373
|
const retryQueue: boolean =
|
|
360
374
|
legacySettings.integrations['Hightouch.io']?.retryQueue ?? true
|
|
361
375
|
|
|
376
|
+
if (!options.disableClientPersistence && options.httpCookieServiceOptions) {
|
|
377
|
+
options.httpCookieService = await HTTPCookieService.load(
|
|
378
|
+
options.httpCookieServiceOptions
|
|
379
|
+
).catch((err): undefined => console.error(err) as undefined)
|
|
380
|
+
}
|
|
381
|
+
|
|
362
382
|
const opts: InitOptions = { retryQueue, ...options }
|
|
363
383
|
const analytics = new Analytics(settings, opts)
|
|
364
384
|
|
|
@@ -55,6 +55,11 @@ import {
|
|
|
55
55
|
import { PluginFactory } from '../../plugins/remote-loader'
|
|
56
56
|
import { setGlobalAnalytics } from '../../lib/global-analytics-helper'
|
|
57
57
|
import { popPageContext } from '../buffer'
|
|
58
|
+
import type {
|
|
59
|
+
HTTPCookieService,
|
|
60
|
+
HTTPCookieServiceOptions,
|
|
61
|
+
} from '../http-cookies'
|
|
62
|
+
import type { DestinationSettings } from '../../plugins/destinations'
|
|
58
63
|
|
|
59
64
|
const deprecationWarning =
|
|
60
65
|
'This is being deprecated and will be not be available in future releases of Analytics JS'
|
|
@@ -129,6 +134,20 @@ export interface InitOptions {
|
|
|
129
134
|
*/
|
|
130
135
|
globalAnalyticsKey?: string
|
|
131
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Allows specifying plugins as configuration. Used to load plugins in `plugins/destinations/*`.
|
|
139
|
+
*/
|
|
140
|
+
destinations?: Record<string, DestinationSettings>
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* When setting httpCookieServiceOptions, an HTTPCookieService is automatically created
|
|
144
|
+
*/
|
|
145
|
+
httpCookieServiceOptions?: HTTPCookieServiceOptions
|
|
146
|
+
/**
|
|
147
|
+
* When not setting httpCookieServiceOptions, you may pass your own instance of HTTPCookieService
|
|
148
|
+
*/
|
|
149
|
+
httpCookieService?: HTTPCookieService
|
|
150
|
+
|
|
132
151
|
/**
|
|
133
152
|
* Shortcuts for overriding the default Hightouch.io integration settings
|
|
134
153
|
*/
|
|
@@ -191,6 +210,7 @@ export class Analytics
|
|
|
191
210
|
{
|
|
192
211
|
persist: !disablePersistance,
|
|
193
212
|
storage: options?.storage,
|
|
213
|
+
httpCookieService: options?.httpCookieService,
|
|
194
214
|
// Any User specific options override everything else
|
|
195
215
|
...options?.user,
|
|
196
216
|
},
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# HTTPOnly Cookies
|
|
2
|
+
|
|
3
|
+
## "Browser Cookies" vs "Server Cookies"
|
|
4
|
+
|
|
5
|
+
**Cookies are serialized sets of key-value pairs that the browser can send to the server when making an HTTP request (e.g. on the `Cookie` header)**. Traditionally, the browser does this to identify itself when calling the server. For example, it might receive a cookie when calling a `/login` route, and then continue to send this cookie on subsequent requests, proving that the user is still logged-in. Alternatively, browsers can use cookies just for storing local data, like localStorage.
|
|
6
|
+
|
|
7
|
+
Normally, **both** the client and the server can CRUD cookies.
|
|
8
|
+
|
|
9
|
+
However, as a security measure, browsers restrict Javascript access to cookies containing the `HTTPOnly` property. These cookies are **only** intended to be created and read by the server.
|
|
10
|
+
|
|
11
|
+
## "Browser Cookies" for event attribution
|
|
12
|
+
|
|
13
|
+
Certain browsers (e.g. Safari) limit "Browser Cookies" to a 7 day expiry.
|
|
14
|
+
|
|
15
|
+
A user visiting a website on both 01/01/2023 and 01/14/2023 will look like two different users. The browser will delete the user's "anonymousId cookie" before the user begins their second session on 01/14/2023.
|
|
16
|
+
|
|
17
|
+
## "Server Cookies" for event attribution
|
|
18
|
+
|
|
19
|
+
These expiry limits don't have to apply to "Server Cookies".
|
|
20
|
+
|
|
21
|
+
A user visiting a website on both 01/01/2023 and 01/14/2023 can still look like the same user, provided that there is a way to "regenerate" the user's same "anonymousId cookie" from the earlier session. To do this, the following must happen:
|
|
22
|
+
1. User begins their session on 01/01/2023
|
|
23
|
+
1. Events SDK creates an anonymousId "browser cookie"
|
|
24
|
+
1. Events SDK sends "browser cookie" to `$server` and receives back an HTTPOnly cookie with the same anonymousId
|
|
25
|
+
1. HTTPOnly cookie remains on the user's device
|
|
26
|
+
1. User begins their second session on 01/14/2023
|
|
27
|
+
1. Events SDK sends the HTTPOnly cookie to `$server` and receives back a "browser cookie" with the same anonymousId as the first session
|
|
28
|
+
|
|
29
|
+
**The `$server` must be the same server that serves your website.** Certain browsers (e.g. Safari) will still enforce a 7 day expiry--even for "server cookies"--unless the following criteria are met:
|
|
30
|
+
1. The `$server` providing the HTTPOnly cookie must be on the same domain as the website.
|
|
31
|
+
1. If `$server` is on a subdomain of the website, its IP address must match the IP address that served the main HTML document.
|
|
32
|
+
|
|
33
|
+
Routing a subdomain via DNS will not suffice. You'll need **one of** the following:
|
|
34
|
+
- A **webserver** that serves both your HTML document and an API (e.g. something like Django, Rails, Spring, etc)
|
|
35
|
+
- A **reverse proxy** that can forward requests for your HTML document to one place, your API requests to another, and make it look like it's all on one server (e.g. NGINX, Caddy, etc)
|
|
36
|
+
- A **CDN** that can run programmatic logic when matching certain requests (e.g. Lambda@Edge, Clouflare Workers, etc)
|
|
37
|
+
|
|
38
|
+
## Client SDK Setup
|
|
39
|
+
|
|
40
|
+
```javascript
|
|
41
|
+
import { HtEventsBrowser } from '@ht-sdks/events-sdk-js-browser'
|
|
42
|
+
|
|
43
|
+
const htevents = HtEventsBrowser.load(
|
|
44
|
+
{ writeKey: '<YOUR_WRITE_KEY>'},
|
|
45
|
+
{
|
|
46
|
+
apiHost: "us-east-1.hightouch-events.com", // HtEvents API remains the same
|
|
47
|
+
httpCookieServiceOptions: {
|
|
48
|
+
clearUrl: '/ht/clear', // route hosted on *your* domain and infra
|
|
49
|
+
renewUrl: '/ht/renew', // route hosted on *your* domain and infra
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
htevents.identify('hello world')
|
|
55
|
+
|
|
56
|
+
document.body?.addEventListener('click', () => {
|
|
57
|
+
htevents.track('document body clicked!')
|
|
58
|
+
})
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Server Setup
|
|
62
|
+
|
|
63
|
+
The Events SDK expects to interact with a customer's `$server` that implements a specific spec for two routes. You can name the endpoints whatever you want.
|
|
64
|
+
|
|
65
|
+
### An API for **creating** server and browser cookies
|
|
66
|
+
|
|
67
|
+
This route should look for the following **browser** cookies (from Events SDK):
|
|
68
|
+
* `request.headers.get('Cookie')["htjs_anonymous_id"]`
|
|
69
|
+
* `request.headers.get('Cookie')["htjs_user_id"]`
|
|
70
|
+
|
|
71
|
+
This route should return these values as **server** cookies:
|
|
72
|
+
* `response.cookie("htjs_anonymous_id_srvr", anonVal, {httpOnly:true, ...})`
|
|
73
|
+
* `response.cookie("htjs_user_id_srvr", userIdVal, {httpOnly:true, ...})`
|
|
74
|
+
|
|
75
|
+
If there are no browser cookies found, return any server cookies as browser cookies:
|
|
76
|
+
* `response.cookie("htjs_anonymous_id", anonVal, ...)`
|
|
77
|
+
* `response.cookie("htjs_user_id", userIdVal, ...)`
|
|
78
|
+
|
|
79
|
+
### An API for **clearing** server cookies
|
|
80
|
+
|
|
81
|
+
This route should look for **server** cookies and clean them:
|
|
82
|
+
* `res.cookie("htjs_anonymous_id_srvr", "", {maxAge: 0, httpOnly:true, ...});`
|
|
83
|
+
* `res.cookie("htjs_user_id_srvr", "", {maxAge: 0, httpOnly:true, ...});`
|
|
84
|
+
|
|
85
|
+
### API Spec
|
|
86
|
+
The spec of the actual `request` and `response` payloads are kept intentionally vague. The spec should fit a variety of server environments.
|
|
87
|
+
|
|
88
|
+
The Events SDK only requires that the server: A) handles cookies and B) returns a `200` status code.
|
|
89
|
+
|
|
90
|
+
## Server Examples
|
|
91
|
+
|
|
92
|
+
- [Express.js and NGINX](./server-examples/node-express-js.md)
|
|
93
|
+
- [Next.js and Vercel](./server-examples/node-next-js.md)
|
|
94
|
+
- [AWS Lambda and API Gateway](./server-examples/node-aws-lambda.md)
|
|
95
|
+
|
|
96
|
+
## More information
|
|
97
|
+
- Safari: https://webkit.org/blog/9521/intelligent-tracking-prevention-2-3/
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { fetch } from '../../lib/fetch'
|
|
2
|
+
|
|
3
|
+
type DeferredRequest = () => Promise<Response>
|
|
4
|
+
|
|
5
|
+
export type HTTPCookieServiceOptions = {
|
|
6
|
+
renewUrl: string
|
|
7
|
+
clearUrl: string
|
|
8
|
+
retries?: number
|
|
9
|
+
backoff?: number
|
|
10
|
+
flushInterval?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `HTTPCookieService.load(...)` should be awaited inside `loadAnalytics(...)`.
|
|
15
|
+
* If the server can recreate an expired "Browser Cookie", by inspecting existing
|
|
16
|
+
* "Server Cookies", we should delay dispatching events until receiving the Cookie.
|
|
17
|
+
*
|
|
18
|
+
* dispatch$Method class methods should be used after any cookie interactions:
|
|
19
|
+
* `dispatchCreate()` should be called after creating any new browser cookies.
|
|
20
|
+
* `dispatchClear()` should be called after clearing any browser cookies.
|
|
21
|
+
*
|
|
22
|
+
* Manual `startQueueConsumer()` or `stopQueueConsumer()` is not usually necessary.
|
|
23
|
+
*
|
|
24
|
+
* Glossary:
|
|
25
|
+
* "Server Cookies": `HTTPOnly:true`, stored on client, but only server can access.
|
|
26
|
+
* "Browser Cookies": `HTTPOnly:false`, stored on client, and both client and server can access.
|
|
27
|
+
*/
|
|
28
|
+
export class HTTPCookieService {
|
|
29
|
+
private queue: DeferredRequest[]
|
|
30
|
+
private renewUrl: string
|
|
31
|
+
private clearUrl: string
|
|
32
|
+
private retries: number
|
|
33
|
+
private backoff: number
|
|
34
|
+
private flushInterval: number
|
|
35
|
+
private flushIntervalId?: NodeJS.Timer
|
|
36
|
+
|
|
37
|
+
private constructor(options: HTTPCookieServiceOptions) {
|
|
38
|
+
const urls = HTTPCookieService.urlHelper(options)
|
|
39
|
+
this.renewUrl = urls.renewUrl
|
|
40
|
+
this.clearUrl = urls.clearUrl
|
|
41
|
+
|
|
42
|
+
this.backoff = options.backoff ?? 300
|
|
43
|
+
this.retries = options.retries ?? 3
|
|
44
|
+
this.flushInterval = options.flushInterval ?? 1000
|
|
45
|
+
this.queue = []
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static urlHelper(options: HTTPCookieServiceOptions): {
|
|
49
|
+
renewUrl: string
|
|
50
|
+
clearUrl: string
|
|
51
|
+
} {
|
|
52
|
+
const origin = window.location.origin
|
|
53
|
+
return {
|
|
54
|
+
renewUrl: new URL(options.renewUrl, origin).href,
|
|
55
|
+
clearUrl: new URL(options.clearUrl, origin).href,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static async load(
|
|
60
|
+
options: HTTPCookieServiceOptions
|
|
61
|
+
): Promise<HTTPCookieService> {
|
|
62
|
+
const cookieService = new HTTPCookieService(options)
|
|
63
|
+
|
|
64
|
+
// renew any existing HTTPCookies already on the device
|
|
65
|
+
// we want `load()` to block on this, so await directly instead of calling dispatch
|
|
66
|
+
const req = cookieService.sendHTTPCookies(cookieService.renewUrl)
|
|
67
|
+
await retry(req, cookieService.retries, cookieService.backoff).catch(
|
|
68
|
+
console.error
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
// consume HTTPCookie actions, sequentially, as needed
|
|
72
|
+
cookieService.startQueueConsumer()
|
|
73
|
+
|
|
74
|
+
return cookieService
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
dispatchCreate() {
|
|
78
|
+
this.queue.push(this.sendHTTPCookies(this.renewUrl))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
dispatchClear() {
|
|
82
|
+
this.queue.push(this.sendHTTPCookies(this.clearUrl))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
startQueueConsumer() {
|
|
86
|
+
if (this.flushIntervalId) {
|
|
87
|
+
console.error('HTTPCookie queue consumer is already running.')
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
const bound = this.consumeQueue.bind(this)
|
|
91
|
+
this.flushIntervalId = setInterval(
|
|
92
|
+
() => bound().catch(console.error),
|
|
93
|
+
this.flushInterval
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
stopQueueConsumer() {
|
|
98
|
+
if (!this.flushIntervalId) {
|
|
99
|
+
console.error('HTTPCookie queue consumer is already stopped.')
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
clearInterval(this.flushIntervalId)
|
|
103
|
+
this.flushIntervalId = undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private sendHTTPCookies(serviceUrl: string): DeferredRequest {
|
|
107
|
+
return async function (): Promise<Response> {
|
|
108
|
+
return await fetch(serviceUrl, {
|
|
109
|
+
credentials: 'include',
|
|
110
|
+
headers: {
|
|
111
|
+
Accept: 'application/json',
|
|
112
|
+
'Content-Type': 'application/json',
|
|
113
|
+
},
|
|
114
|
+
method: 'post',
|
|
115
|
+
body: JSON.stringify({
|
|
116
|
+
sentAt: new Date().toISOString(),
|
|
117
|
+
}),
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* This queue exists to avoid race conditions.
|
|
124
|
+
*
|
|
125
|
+
* Customer-developers may not `await` all promises.
|
|
126
|
+
*
|
|
127
|
+
* Therefore, introducing async code into analytics.track(), etc
|
|
128
|
+
* could create race conditions in customer code.
|
|
129
|
+
*
|
|
130
|
+
* The queue enforces: if someone calls analytics.clear()
|
|
131
|
+
* before calling analytics.identify(), the cookie service
|
|
132
|
+
* will consume those actions, sequentially, even if no promises
|
|
133
|
+
* are awaited.
|
|
134
|
+
*/
|
|
135
|
+
private async consumeQueue() {
|
|
136
|
+
while (this.queue.length > 0) {
|
|
137
|
+
const req = this.queue.shift() as DeferredRequest
|
|
138
|
+
await retry(req, this.retries, this.backoff).catch(console.error)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function sleep(delayMS: number): Promise<void> {
|
|
144
|
+
return new Promise((resolve) => setTimeout(resolve, delayMS))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function retry(
|
|
148
|
+
req: DeferredRequest,
|
|
149
|
+
retries: number,
|
|
150
|
+
backoff: number
|
|
151
|
+
): Promise<Response> {
|
|
152
|
+
while (retries >= 0) {
|
|
153
|
+
try {
|
|
154
|
+
return await req().then((res) => {
|
|
155
|
+
if (res.ok) return res
|
|
156
|
+
throw new Error(`Status: ${res.status} ${res.statusText}`)
|
|
157
|
+
})
|
|
158
|
+
} catch (error) {
|
|
159
|
+
retries -= 1
|
|
160
|
+
if (retries <= 0) throw error
|
|
161
|
+
await sleep(backoff)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
throw Error('HtEvents: Problem with DeferredRequest')
|
|
165
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
**These server examples should not be used as is. They should be adapted to your setup and "productionized".**
|
|
2
|
+
|
|
3
|
+
An example HTTPCookieService written as an AWS Lambda Function:
|
|
4
|
+
|
|
5
|
+
```Javascript
|
|
6
|
+
const USER_COOKIE = "htjs_user_id";
|
|
7
|
+
const ANON_COOKIE = "htjs_anonymous_id";
|
|
8
|
+
const DOMAIN = "CHANGEME.example.com";
|
|
9
|
+
|
|
10
|
+
function renewCookies(request, response, browserName, serverName) {
|
|
11
|
+
let cookie = request.cookies[browserName] ?? request.cookies[serverName];
|
|
12
|
+
if (!cookie) return "";
|
|
13
|
+
const maxAge = 31_536_000; // 1 year in seconds
|
|
14
|
+
response.cookies= (response.cookies ?? []).concat([
|
|
15
|
+
`${browserName}=${cookie}; Max-Age=${maxAge}; Domain=${DOMAIN}; Path=/; SameSite=Lax;`,
|
|
16
|
+
`${serverName}=${cookie}; Max-Age=${maxAge}; Domain=${DOMAIN}; Path=/; SameSite=Lax; httpOnly=true;`,
|
|
17
|
+
]);
|
|
18
|
+
return cookie;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function clearServerCookie(request, response, serverName) {
|
|
22
|
+
const cookie = "";
|
|
23
|
+
const maxAge = 0;
|
|
24
|
+
response.cookies = (response.cookies ?? []).concat([
|
|
25
|
+
`${serverName}=${cookie}; Max-Age=${maxAge}; Domain=${DOMAIN}; Path=/; SameSite=Lax; httpOnly;`,
|
|
26
|
+
]);
|
|
27
|
+
return cookie;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getCookies(reqCookies) {
|
|
31
|
+
const cookies = {};
|
|
32
|
+
if (!reqCookies) return cookies;
|
|
33
|
+
for (const cookieStr of reqCookies) {
|
|
34
|
+
const cookieArr = cookieStr.split("=");
|
|
35
|
+
if (cookieArr.length == 1) {
|
|
36
|
+
cookies[cookieArr[0]] = "";
|
|
37
|
+
} else if (cookieArr.length == 2) {
|
|
38
|
+
cookies[cookieArr[0]] = cookieArr[1];
|
|
39
|
+
} else {
|
|
40
|
+
console.log("cookieArr", cookieArr);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return cookies;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const handler = async (event, context) => {
|
|
47
|
+
const request = {
|
|
48
|
+
method: event.requestContext.http.method,
|
|
49
|
+
path: event.pathParameters.proxy,
|
|
50
|
+
headers: event.headers,
|
|
51
|
+
cookies: getCookies(event.cookies),
|
|
52
|
+
}
|
|
53
|
+
const response = {
|
|
54
|
+
statusCode: '200',
|
|
55
|
+
headers: {
|
|
56
|
+
"Content-Type": "application/json"
|
|
57
|
+
},
|
|
58
|
+
isBase64Encoded: false,
|
|
59
|
+
multiValueHeaders: {},
|
|
60
|
+
body: "{}",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (request.method?.toUpperCase() !== "POST") {
|
|
64
|
+
response.statusCode = 404;
|
|
65
|
+
return response;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (request.path === "renew") {
|
|
69
|
+
const userId = renewCookies(request, response, USER_COOKIE, `${USER_COOKIE}_srvr`);
|
|
70
|
+
const anonymousId = renewCookies(request, response, ANON_COOKIE, `${ANON_COOKIE}_srvr`);
|
|
71
|
+
response.body = JSON.stringify({
|
|
72
|
+
userId,
|
|
73
|
+
anonymousId,
|
|
74
|
+
})
|
|
75
|
+
} else if (request.path === "clear") {
|
|
76
|
+
const userId = clearServerCookie(request, response, `${USER_COOKIE}_srvr`);
|
|
77
|
+
const anonymousId = clearServerCookie(request, response, `${ANON_COOKIE}_srvr`);
|
|
78
|
+
response.body = JSON.stringify({
|
|
79
|
+
userId,
|
|
80
|
+
anonymousId,
|
|
81
|
+
})
|
|
82
|
+
} else {
|
|
83
|
+
response.statusCode = 404;
|
|
84
|
+
}
|
|
85
|
+
return response;
|
|
86
|
+
};
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
After creating the above Lambda Function, you'll need to setup an [API Gateway](https://aws.amazon.com/api-gateway/). Specifically, create a new `Route` and input the value of `/ht/{proxy}`. Then attach an `integration` for this route, and select your Lambda. Your HTTPCookieService lambda will now be callable over HTTP at `${ApiGatewayURL}.com/default/ht/renew`.
|
|
90
|
+
|
|
91
|
+
However, the HTTPCookieService must live on the same domain and IP address as your website's HTML document.
|
|
92
|
+
|
|
93
|
+
As one way to accomplish this, you could use a CDN to front both your HTML document and your API Gateway. To do this, create a Cloudfront Distribution. Then create multiple origin configurations. One will have a `customOriginSource`, and a `pathPattern` of `/ht/*` pointing at your API Gateway's URL. The other origin configuration will point at an `s3OriginSource` if your HTML website is being hosted on S3. You would then configure your Web SDK to point at `/ht/renew` and `/ht/clear` for the HTTPCookieService routes.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
Alternatively, if you simply want a "Hello World" example for a fully functioning HTTPCookieService, you can create two lambdas (one for HTML and another for HTTPCookieService), and put them both on the same API Gateway. You can then test against the API Gateway domain.
|
|
98
|
+
|
|
99
|
+
Example "Lambda for HTML". This is only intended for testing and "Hello World" purposes:
|
|
100
|
+
|
|
101
|
+
```Javascript
|
|
102
|
+
const cdnDomain = "https://cdn.hightouch-events.com/browser/release/v1-latest/events.min.js";
|
|
103
|
+
|
|
104
|
+
const writeKey = "WRITE KEY";
|
|
105
|
+
|
|
106
|
+
const html = `
|
|
107
|
+
<head>
|
|
108
|
+
<script type="text/javascript">
|
|
109
|
+
!function(){var e=window.htevents=window.htevents||[];if(!e.initialize)if(e.invoked)window.console&&console.error&&console.error("Hightouch snippet included twice.");else{e.invoked=!0,e.methods=["trackSubmit","trackClick","trackLink","trackForm","pageview","identify","reset","group","track","ready","alias","debug","page","once","off","on","addSourceMiddleware","addIntegrationMiddleware","setAnonymousId","addDestinationMiddleware"],e.factory=function(t){return function(){var n=Array.prototype.slice.call(arguments);return n.unshift(t),e.push(n),e}};for(var t=0;t<e.methods.length;t++){var n=e.methods[t];e[n]=e.factory(n)}e.load=function(t,n){var o=document.createElement("script");o.type="text/javascript",o.async=!0,o.src="${cdnDomain}";var r=document.getElementsByTagName("script")[0];r.parentNode.insertBefore(o,r),e._loadOptions=n,e._writeKey=t},e.SNIPPET_VERSION="0.0.1",
|
|
110
|
+
e.load('${writeKey}',{
|
|
111
|
+
apiHost:'us-east-1.hightouch-events.com',
|
|
112
|
+
httpCookieServiceOptions: {clearUrl: 'default/ht/clear', renewUrl: 'default/ht/renew', backoff: 5000},
|
|
113
|
+
}),
|
|
114
|
+
e.page()}}();
|
|
115
|
+
</script>
|
|
116
|
+
|
|
117
|
+
</head>
|
|
118
|
+
<body>
|
|
119
|
+
Hightouch
|
|
120
|
+
<a href="#" onClick="(function(){
|
|
121
|
+
htevents.identify(
|
|
122
|
+
'123', {
|
|
123
|
+
email: 'bob@hightouch.io'
|
|
124
|
+
}, {},
|
|
125
|
+
() => {
|
|
126
|
+
console.log('identify call');
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
})();return false;">identify
|
|
130
|
+
</a>
|
|
131
|
+
<br>
|
|
132
|
+
<br>
|
|
133
|
+
<a href="#" onClick="(function(){
|
|
134
|
+
htevents.track(
|
|
135
|
+
'clickEvent', {
|
|
136
|
+
revenue: 30,
|
|
137
|
+
currency: 'USD',
|
|
138
|
+
user_actual_id: 123
|
|
139
|
+
}, {},
|
|
140
|
+
() => {
|
|
141
|
+
console.log('track call');
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
})();return false;">track
|
|
145
|
+
</a>
|
|
146
|
+
<br>
|
|
147
|
+
<br>
|
|
148
|
+
<a href="#" onClick="(function(){
|
|
149
|
+
htevents.reset();
|
|
150
|
+
htevents.identify('456', { email: 'george@hightouch.com'})
|
|
151
|
+
})();return false;">reset
|
|
152
|
+
</a>
|
|
153
|
+
</body>
|
|
154
|
+
`;
|
|
155
|
+
|
|
156
|
+
export const handler = async () => {
|
|
157
|
+
const response = {
|
|
158
|
+
statusCode: 200,
|
|
159
|
+
headers: {
|
|
160
|
+
'Content-Type': 'text/html',
|
|
161
|
+
},
|
|
162
|
+
body: html,
|
|
163
|
+
};
|
|
164
|
+
return response;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
```
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
**These server examples should not be used as is. They should be adapted to your setup and "productionized".**
|
|
2
|
+
|
|
3
|
+
An example HTTPCookieService written in Node.js/Express.js:
|
|
4
|
+
|
|
5
|
+
```Javascript
|
|
6
|
+
const express = require("express");
|
|
7
|
+
const cookieParser = require('cookie-parser');
|
|
8
|
+
const cors = require('cors');
|
|
9
|
+
|
|
10
|
+
const USER_COOKIE = "htjs_user_id";
|
|
11
|
+
const ANON_COOKIE = "htjs_anonymous_id";
|
|
12
|
+
|
|
13
|
+
function getDomain(req) {
|
|
14
|
+
let domain = process.env.DOMAIN || req.headers["x-forwarded-for"] || req.get("host");
|
|
15
|
+
if (domain.startsWith("localhost")) return "localhost";
|
|
16
|
+
return domain;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function renewCookies(req, res, browserName, serverName) {
|
|
20
|
+
const cookie = req.cookies[browserName] || req.cookies[serverName];
|
|
21
|
+
if (!cookie) return "";
|
|
22
|
+
const cookieParams = {maxAge:31536000*1000, domain: getDomain(req), sameSite: "lax"};
|
|
23
|
+
res.cookie(browserName, cookie, {...cookieParams});
|
|
24
|
+
res.cookie(serverName, cookie, {...cookieParams, httpOnly:true});
|
|
25
|
+
return cookie;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function clearServerCookie(req, res, serverName) {
|
|
29
|
+
const cookie = "";
|
|
30
|
+
const cookieParams = {maxAge:0, domain: getDomain(req), sameSite: "lax"};
|
|
31
|
+
res.cookie(serverName, "", {...cookieParams, httpOnly:true});
|
|
32
|
+
return cookie;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const app = express();
|
|
36
|
+
app.use(cookieParser());
|
|
37
|
+
app.use(cors())
|
|
38
|
+
|
|
39
|
+
app.post("/ht/renew", (req, res) => {
|
|
40
|
+
// recreate a browser cookie from an existing server cookie, OR
|
|
41
|
+
// create a new server cookie that can later be used to recreate from.
|
|
42
|
+
return res.json({
|
|
43
|
+
userId: renewCookies(req, res, USER_COOKIE, `${USER_COOKIE}_srvr`),
|
|
44
|
+
anonymousId: renewCookies(req, res, ANON_COOKIE, `${ANON_COOKIE}_srvr`),
|
|
45
|
+
})
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
app.post("/ht/clear", (req, res) => {
|
|
49
|
+
// clear server cookies, e.g. if the user asks to clear all cookies.
|
|
50
|
+
return res.json({
|
|
51
|
+
userId: clearServerCookie(req, res, `${USER_COOKIE}_srvr`),
|
|
52
|
+
anonymousId: clearServerCookie(req, res, `${ANON_COOKIE}_srvr`),
|
|
53
|
+
})
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
app.listen(process.env.PORT || 3000, () => {
|
|
57
|
+
console.log(`Listening on port ${process.env.PORT}...`);
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The HTTPCookieService must live on the same domain and IP address as your website's HTML document.
|
|
62
|
+
|
|
63
|
+
As one way to accomplish this, you could have NGINX both serve your HTML document and forward requests to the Node.js/Express server.
|
|
64
|
+
|
|
65
|
+
An **overly simplified** NGINX reverse proxy:
|
|
66
|
+
```
|
|
67
|
+
worker_processes 1;
|
|
68
|
+
|
|
69
|
+
events {
|
|
70
|
+
worker_connections 1024;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
http {
|
|
74
|
+
default_type application/octet-stream;
|
|
75
|
+
sendfile on;
|
|
76
|
+
keepalive_timeout 65;
|
|
77
|
+
|
|
78
|
+
server {
|
|
79
|
+
listen 8080;
|
|
80
|
+
server_name localhost;
|
|
81
|
+
|
|
82
|
+
location / {
|
|
83
|
+
root /Users/name/src/website/html;
|
|
84
|
+
index index.html index.htm;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
location /cdn {
|
|
88
|
+
#autoindex on;
|
|
89
|
+
alias /Users/name/src/website/cdn;
|
|
90
|
+
try_files $uri /index.html =404;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
location /ht {
|
|
94
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
95
|
+
proxy_set_header Host $host;
|
|
96
|
+
proxy_pass http://127.0.0.1:3000;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
include servers/*;
|
|
102
|
+
}
|
|
103
|
+
```
|