ytracking-web 0.1.1
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 +73 -0
- package/package.json +25 -0
- package/src/client.ts +578 -0
- package/src/idb.ts +103 -0
- package/src/index.ts +19 -0
- package/src/payload.ts +208 -0
- package/src/retry.ts +30 -0
- package/src/transport.ts +65 -0
- package/src/types.ts +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# ytracking-web(瀏覽器 SDK)
|
|
2
|
+
|
|
3
|
+
TypeScript 實作,對齊 [docs/SDK_DESIGN.md](../../docs/SDK_DESIGN.md):事件先入 **IndexedDB** 佇列,背景 **flush**、**退避重試**、**多 `baseUrl` 故障轉移**;失敗時可降級為記憶體佇列(不持久)。
|
|
4
|
+
|
|
5
|
+
## 建置產物(提交於 `site/public/sdk/`)
|
|
6
|
+
|
|
7
|
+
| 檔案 | 說明 |
|
|
8
|
+
|------|------|
|
|
9
|
+
| `ytracking-web.js` | IIFE,`globalThis.YTrackingWeb` |
|
|
10
|
+
| `ytracking-web.min.js` | 同上,minify |
|
|
11
|
+
| `ytracking-web.mjs` | ESM `import { init, track } from '...'`(部署路徑依站點) |
|
|
12
|
+
|
|
13
|
+
根目錄執行:`npm run build:web-sdk`
|
|
14
|
+
## npm 套件(選用)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install ytracking-web
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
套件 **exports** 指向 **`src/*.ts` 原始碼**(`type: module`);整合專案需使用可轉譯/bundle TypeScript 的工具鏈,或繼續使用倉庫建置之 **`site/public/sdk/*`** 靜態檔。發佈 registry 前請於根目錄執行 **`npm run check:web-sdk`** 與 **`npm run build:web-sdk`** 驗證。
|
|
21
|
+
|
|
22
|
+
## 發佈至 npm
|
|
23
|
+
|
|
24
|
+
1. 於倉庫根目錄:`npm run check:web-sdk`、(建議)`npm run build:web-sdk`。
|
|
25
|
+
2. **GitHub Actions**:「Publish ytracking-web to npm」workflow(`workflow_dispatch`)。需在 repo 設定 **`NPM_TOKEN`**(npm automation access token,具 publish 權限)。
|
|
26
|
+
3. **本機(免 `npm login`,適合首次發佈作法 B)**:在倉庫根目錄設定 **`NPM_TOKEN`** 後執行 **`npm run publish:ytracking-web`**(會先跑 `check:web-sdk` 再 `npm publish`)。亦可手動:`cd packages/ytracking-web && npm publish`(需已 `npm login` 或 `~/.npmrc` token)。
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
## 使用(IIFE)
|
|
30
|
+
|
|
31
|
+
```html
|
|
32
|
+
<script src="https://你的網域/sdk/ytracking-web.js"></script>
|
|
33
|
+
<script>
|
|
34
|
+
YTrackingWeb.init({
|
|
35
|
+
siteId: "站點-UUID",
|
|
36
|
+
baseUrls: ["https://你的網域"],
|
|
37
|
+
fallbackBaseUrls: [],
|
|
38
|
+
endpointListTtlSec: 300,
|
|
39
|
+
domainHealthReportMinIntervalMs: 300000,
|
|
40
|
+
debug: false,
|
|
41
|
+
});
|
|
42
|
+
YTrackingWeb.trackPageView();
|
|
43
|
+
</script>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## API(`YTrackingWeb`)
|
|
47
|
+
|
|
48
|
+
- `init(config)` — 必填 `siteId`、`baseUrls`(無尾階 `/` 之 origin)
|
|
49
|
+
- `enableVisitorFallbackFingerprint`:可選(預設 `false`),僅在無 cookie + 無 localStorage visitor 時以低熵指紋產生暫時 visitor seed
|
|
50
|
+
- `maxQueueSize`:本地 queue 容量上限(預設 `500`),超量時淘汰低優先且最舊事件
|
|
51
|
+
- `batchSize`:單次 flush 最多取件數(預設 `8`)
|
|
52
|
+
- `flushConcurrency`:單次 flush 內平行送件 worker 數(預設 `2`,最小 `1`)
|
|
53
|
+
- `endpointListTtlSec`:成功送達後重新拉取 `/v1/sdk/ingestion-hosts` 的最短間隔(秒,預設 300)
|
|
54
|
+
- `domainHealthReportMinIntervalMs`:同一 domain 不可達回報最短間隔(毫秒,預設 300000)
|
|
55
|
+
- `track(type, { props })` — 對應 `POST /v1/collect` envelope
|
|
56
|
+
- `trackPageView()` — `page_view`
|
|
57
|
+
- `install({ clickId? })` — 內嵌 **`POST /v1/install`**(無 `appId` 之 embed 形態)
|
|
58
|
+
- `flush()` — 手動送出佇列
|
|
59
|
+
- `setContext({ visitorId, sessionId, clickId, campaignId, appId })` — 後續事件帶入
|
|
60
|
+
- `shutdown()` — 停止定時 flush
|
|
61
|
+
|
|
62
|
+
## 與後端的關係
|
|
63
|
+
|
|
64
|
+
- 每筆事件在 `event.props` 附 **`_ytSdkEventId`**(支援 `crypto.randomUUID` 時為 UUID)與 **`_ytSdk: "web-v1"`**;同時在 JSON 根層帶 **`idempotencyKey`**(與該 UUID 相同),與 **`POST /v1/collect`** 去重契約對齊(見 [API.md](../../docs/API.md) §2.2)。重試/重送同一佇列項目時沿用同一 id,consumer 只會落庫一次。
|
|
65
|
+
- `collect` 出站 `Idempotency-Key` 會優先使用 payload 根層 `idempotencyKey`(若缺失才回退 queue id),確保 header 與 body 同鍵。
|
|
66
|
+
- collect 成功回應若包含 `visitorId`,SDK 會寫入 localStorage(`yt_vid_v1`)並自動帶入後續事件 context;可在 cookie 受限環境維持 visitor 關聯。
|
|
67
|
+
- 須符合 `sites.allowed_origins`(見 [SITE_AND_EMBED.md](../../docs/SITE_AND_EMBED.md))。
|
|
68
|
+
- fallback 會維護 endpoint pool + cursor;成功入口可刷新新 hosts,不可達時會送 best-effort `domain-health/report`(含節流)。
|
|
69
|
+
- `Retry-After` 同時支援秒數與 HTTP-date;`shutdown()` 會解除 `visibilitychange` listener,避免重複初始化時累積監聽器。
|
|
70
|
+
|
|
71
|
+
## 型別檢查
|
|
72
|
+
|
|
73
|
+
`npm run check:web-sdk`
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ytracking-web",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Browser SDK for YTracking collect/install (IndexedDB queue, retry, multi-host)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"import": "./src/index.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": ["src", "README.md"],
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"keywords": ["ytracking", "analytics", "tracking", "indexeddb"],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/TI-Eddie/YTracking.git",
|
|
20
|
+
"directory": "packages/ytracking-web"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import {
|
|
2
|
+
backoffMs,
|
|
3
|
+
eventPriority,
|
|
4
|
+
isNonRetryableStatus,
|
|
5
|
+
isRetryableStatus,
|
|
6
|
+
} from "./retry";
|
|
7
|
+
import {
|
|
8
|
+
idbAdd,
|
|
9
|
+
idbCount,
|
|
10
|
+
idbDelete,
|
|
11
|
+
idbGetAll,
|
|
12
|
+
idbGetDue,
|
|
13
|
+
idbPut,
|
|
14
|
+
openOutboxDb,
|
|
15
|
+
} from "./idb";
|
|
16
|
+
import {
|
|
17
|
+
buildCollectEnvelope,
|
|
18
|
+
buildSoftFingerprintVisitorId,
|
|
19
|
+
readStoredVisitorId,
|
|
20
|
+
writeStoredVisitorId,
|
|
21
|
+
} from "./payload";
|
|
22
|
+
import { postJson } from "./transport";
|
|
23
|
+
import type { InitConfig, OutboxRecord } from "./types";
|
|
24
|
+
|
|
25
|
+
function log(debug: boolean | undefined, ...args: unknown[]): void {
|
|
26
|
+
if (debug) console.log("[YTrackingWeb]", ...args);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeOrigins(urls: string[]): string[] {
|
|
30
|
+
const out: string[] = [];
|
|
31
|
+
for (const u of urls) {
|
|
32
|
+
const t = String(u || "")
|
|
33
|
+
.trim()
|
|
34
|
+
.replace(/\/+$/, "");
|
|
35
|
+
if (t) out.push(t);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function collectUrl(origin: string): string {
|
|
41
|
+
return `${origin}/v1/collect`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function installUrl(origin: string): string {
|
|
45
|
+
return `${origin}/v1/install`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class YTrackingWebClient {
|
|
49
|
+
private cfg: Required<
|
|
50
|
+
Pick<InitConfig, "siteId" | "baseUrls"> & {
|
|
51
|
+
fallbackBaseUrls: string[];
|
|
52
|
+
debug: boolean;
|
|
53
|
+
enableVisitorFallbackFingerprint: boolean;
|
|
54
|
+
maxQueueSize: number;
|
|
55
|
+
flushIntervalMs: number;
|
|
56
|
+
requestTimeoutMs: number;
|
|
57
|
+
batchSize: number;
|
|
58
|
+
flushConcurrency: number;
|
|
59
|
+
maxAttempts: number;
|
|
60
|
+
endpointListTtlSec: number;
|
|
61
|
+
domainHealthReportMinIntervalMs: number;
|
|
62
|
+
}
|
|
63
|
+
>;
|
|
64
|
+
private db: IDBDatabase | null = null;
|
|
65
|
+
private mem: OutboxRecord[] = [];
|
|
66
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
67
|
+
private flushing = false;
|
|
68
|
+
private originPool: string[] = [];
|
|
69
|
+
private originCursor = 0;
|
|
70
|
+
private lastEndpointListRefreshAt = 0;
|
|
71
|
+
private lastDomainHealthReportAt = new Map<string, number>();
|
|
72
|
+
private visibilityHandler: (() => void) | null = null;
|
|
73
|
+
private context: {
|
|
74
|
+
visitorId?: string;
|
|
75
|
+
sessionId?: string;
|
|
76
|
+
clickId?: string;
|
|
77
|
+
campaignId?: string;
|
|
78
|
+
appId?: string;
|
|
79
|
+
} = {};
|
|
80
|
+
|
|
81
|
+
constructor(cfg: InitConfig) {
|
|
82
|
+
const base = normalizeOrigins(cfg.baseUrls);
|
|
83
|
+
if (!cfg.siteId?.trim() || base.length === 0) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
"YTrackingWeb: siteId and at least one baseUrl are required",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
this.cfg = {
|
|
89
|
+
siteId: cfg.siteId.trim(),
|
|
90
|
+
baseUrls: base,
|
|
91
|
+
fallbackBaseUrls: normalizeOrigins(cfg.fallbackBaseUrls ?? []),
|
|
92
|
+
debug: !!cfg.debug,
|
|
93
|
+
enableVisitorFallbackFingerprint: !!cfg.enableVisitorFallbackFingerprint,
|
|
94
|
+
maxQueueSize: cfg.maxQueueSize ?? 500,
|
|
95
|
+
flushIntervalMs: cfg.flushIntervalMs ?? 3000,
|
|
96
|
+
requestTimeoutMs: cfg.requestTimeoutMs ?? 15000,
|
|
97
|
+
batchSize: cfg.batchSize ?? 8,
|
|
98
|
+
flushConcurrency: Math.max(1, cfg.flushConcurrency ?? 2),
|
|
99
|
+
maxAttempts: cfg.maxAttempts ?? 12,
|
|
100
|
+
endpointListTtlSec: cfg.endpointListTtlSec ?? 300,
|
|
101
|
+
domainHealthReportMinIntervalMs:
|
|
102
|
+
cfg.domainHealthReportMinIntervalMs ?? 300_000,
|
|
103
|
+
};
|
|
104
|
+
this.originPool = [
|
|
105
|
+
...new Set([...this.cfg.baseUrls, ...this.cfg.fallbackBaseUrls]),
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
setContext(partial: {
|
|
110
|
+
visitorId?: string;
|
|
111
|
+
sessionId?: string;
|
|
112
|
+
clickId?: string;
|
|
113
|
+
campaignId?: string;
|
|
114
|
+
appId?: string;
|
|
115
|
+
}): void {
|
|
116
|
+
this.context = { ...this.context, ...partial };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async start(): Promise<void> {
|
|
120
|
+
if (!this.context.visitorId) {
|
|
121
|
+
const storedVisitorId = readStoredVisitorId();
|
|
122
|
+
if (storedVisitorId) {
|
|
123
|
+
this.context.visitorId = storedVisitorId;
|
|
124
|
+
} else if (this.cfg.enableVisitorFallbackFingerprint) {
|
|
125
|
+
const fpVisitorId = buildSoftFingerprintVisitorId();
|
|
126
|
+
if (fpVisitorId) {
|
|
127
|
+
this.context.visitorId = fpVisitorId;
|
|
128
|
+
writeStoredVisitorId(fpVisitorId);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
this.db = await openOutboxDb();
|
|
133
|
+
if (!this.db)
|
|
134
|
+
log(
|
|
135
|
+
this.cfg.debug,
|
|
136
|
+
"IndexedDB unavailable; using in-memory queue (not durable)",
|
|
137
|
+
);
|
|
138
|
+
if (this.timer) clearInterval(this.timer);
|
|
139
|
+
this.timer = setInterval(() => {
|
|
140
|
+
void this.flush();
|
|
141
|
+
}, this.cfg.flushIntervalMs);
|
|
142
|
+
if (typeof document !== "undefined" && !this.visibilityHandler) {
|
|
143
|
+
this.visibilityHandler = () => {
|
|
144
|
+
if (document.visibilityState === "hidden") void this.flush();
|
|
145
|
+
};
|
|
146
|
+
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
147
|
+
}
|
|
148
|
+
void this.flush();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
shutdown(): void {
|
|
152
|
+
if (this.timer) {
|
|
153
|
+
clearInterval(this.timer);
|
|
154
|
+
this.timer = null;
|
|
155
|
+
}
|
|
156
|
+
if (
|
|
157
|
+
typeof document !== "undefined" &&
|
|
158
|
+
this.visibilityHandler &&
|
|
159
|
+
document.removeEventListener
|
|
160
|
+
) {
|
|
161
|
+
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
162
|
+
}
|
|
163
|
+
this.visibilityHandler = null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async track(
|
|
167
|
+
eventType: string,
|
|
168
|
+
options?: { props?: Record<string, unknown> },
|
|
169
|
+
): Promise<void> {
|
|
170
|
+
const props =
|
|
171
|
+
options?.props && typeof options.props === "object" ? options.props : {};
|
|
172
|
+
const envelope = buildCollectEnvelope(
|
|
173
|
+
this.cfg.siteId,
|
|
174
|
+
eventType,
|
|
175
|
+
props,
|
|
176
|
+
this.context,
|
|
177
|
+
);
|
|
178
|
+
const payload = JSON.stringify(envelope);
|
|
179
|
+
await this.enqueue({
|
|
180
|
+
kind: "collect",
|
|
181
|
+
payload,
|
|
182
|
+
eventType,
|
|
183
|
+
priority: eventPriority(eventType),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async trackPageView(): Promise<void> {
|
|
188
|
+
return this.track("page_view", {});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async install(opts?: { clickId?: string }): Promise<void> {
|
|
192
|
+
const body: Record<string, string> = { siteId: this.cfg.siteId };
|
|
193
|
+
const cid = opts?.clickId?.trim() || this.context.clickId?.trim();
|
|
194
|
+
if (cid) body.clickId = cid;
|
|
195
|
+
await this.enqueue({
|
|
196
|
+
kind: "install",
|
|
197
|
+
payload: JSON.stringify(body),
|
|
198
|
+
eventType: "install",
|
|
199
|
+
priority: 10,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async enqueue(p: {
|
|
204
|
+
kind: OutboxRecord["kind"];
|
|
205
|
+
payload: string;
|
|
206
|
+
eventType: string;
|
|
207
|
+
priority: number;
|
|
208
|
+
}): Promise<void> {
|
|
209
|
+
const id =
|
|
210
|
+
typeof crypto !== "undefined" && crypto.randomUUID
|
|
211
|
+
? crypto.randomUUID()
|
|
212
|
+
: `q_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
const rec: OutboxRecord = {
|
|
215
|
+
id,
|
|
216
|
+
kind: p.kind,
|
|
217
|
+
payload: p.payload,
|
|
218
|
+
attempts: 0,
|
|
219
|
+
nextAttemptAt: now,
|
|
220
|
+
createdAt: now,
|
|
221
|
+
priority: p.priority,
|
|
222
|
+
eventType: p.eventType,
|
|
223
|
+
};
|
|
224
|
+
await this.enforceCapacity();
|
|
225
|
+
if (this.db) {
|
|
226
|
+
const ok = await idbAdd(this.db, rec);
|
|
227
|
+
if (ok) {
|
|
228
|
+
log(this.cfg.debug, "enqueued", p.kind, id);
|
|
229
|
+
void this.flush();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
log(this.cfg.debug, "idb add failed; falling back to memory");
|
|
233
|
+
}
|
|
234
|
+
this.mem.push(rec);
|
|
235
|
+
log(this.cfg.debug, "enqueued (mem)", p.kind, id);
|
|
236
|
+
void this.flush();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private async enforceCapacity(): Promise<void> {
|
|
240
|
+
const persisted = this.db ? await idbCount(this.db) : 0;
|
|
241
|
+
const all = this.db ? await idbGetAll(this.db) : [];
|
|
242
|
+
const total = persisted + this.mem.length;
|
|
243
|
+
if (total < this.cfg.maxQueueSize) return;
|
|
244
|
+
const overflow = total - this.cfg.maxQueueSize + 1;
|
|
245
|
+
const sorted = [...all].sort((a, b) => {
|
|
246
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
247
|
+
return a.createdAt - b.createdAt;
|
|
248
|
+
});
|
|
249
|
+
sorted.push(...this.mem);
|
|
250
|
+
sorted.sort((a, b) => {
|
|
251
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
252
|
+
return a.createdAt - b.createdAt;
|
|
253
|
+
});
|
|
254
|
+
for (let i = 0; i < overflow && i < sorted.length; i++) {
|
|
255
|
+
await this.removeRecord(sorted[i].id);
|
|
256
|
+
log(this.cfg.debug, "dropped low-priority queue item", sorted[i].id);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private async allRecords(): Promise<OutboxRecord[]> {
|
|
261
|
+
if (this.db) {
|
|
262
|
+
const rows = await idbGetAll(this.db);
|
|
263
|
+
return rows.concat(this.mem);
|
|
264
|
+
}
|
|
265
|
+
return [...this.mem];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private async removeRecord(id: string): Promise<void> {
|
|
269
|
+
if (this.db) {
|
|
270
|
+
try {
|
|
271
|
+
await idbDelete(this.db, id);
|
|
272
|
+
} catch {
|
|
273
|
+
log(this.cfg.debug, "idbDelete failed, clearing from memory", id);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
this.mem = this.mem.filter((r) => r.id !== id);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private async updateRecord(rec: OutboxRecord): Promise<void> {
|
|
280
|
+
if (this.db) {
|
|
281
|
+
const inMem = this.mem.some((r) => r.id === rec.id);
|
|
282
|
+
if (!inMem) {
|
|
283
|
+
try {
|
|
284
|
+
await idbPut(this.db, rec);
|
|
285
|
+
} catch {
|
|
286
|
+
log(this.cfg.debug, "idbPut failed, falling back to memory", rec.id);
|
|
287
|
+
this.mem.push(rec);
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const idx = this.mem.findIndex((r) => r.id === rec.id);
|
|
293
|
+
if (idx >= 0) this.mem[idx] = rec;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async flush(): Promise<void> {
|
|
297
|
+
if (this.flushing) return;
|
|
298
|
+
this.flushing = true;
|
|
299
|
+
try {
|
|
300
|
+
const now = Date.now();
|
|
301
|
+
const dbDue = this.db
|
|
302
|
+
? await idbGetDue(this.db, now, this.cfg.batchSize * 2)
|
|
303
|
+
: [];
|
|
304
|
+
const memDue = this.mem.filter((r) => r.nextAttemptAt <= now);
|
|
305
|
+
const due = [...dbDue, ...memDue]
|
|
306
|
+
.sort((a, b) => {
|
|
307
|
+
if (b.priority !== a.priority) return b.priority - a.priority;
|
|
308
|
+
return a.nextAttemptAt - b.nextAttemptAt;
|
|
309
|
+
})
|
|
310
|
+
.slice(0, this.cfg.batchSize);
|
|
311
|
+
if (!due.length) return;
|
|
312
|
+
const workers = Math.min(this.cfg.flushConcurrency, due.length);
|
|
313
|
+
let idx = 0;
|
|
314
|
+
const runOne = async () => {
|
|
315
|
+
while (idx < due.length) {
|
|
316
|
+
const i = idx;
|
|
317
|
+
idx += 1;
|
|
318
|
+
const rec = due[i];
|
|
319
|
+
if (rec) await this.sendOne(rec);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
await Promise.all(Array.from({ length: workers }, () => runOne()));
|
|
323
|
+
} finally {
|
|
324
|
+
this.flushing = false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private originsInOrder(): string[] {
|
|
329
|
+
if (this.originPool.length === 0) {
|
|
330
|
+
this.originPool = [
|
|
331
|
+
...new Set([...this.cfg.baseUrls, ...this.cfg.fallbackBaseUrls]),
|
|
332
|
+
];
|
|
333
|
+
}
|
|
334
|
+
if (this.originPool.length <= 1) return [...this.originPool];
|
|
335
|
+
const idx =
|
|
336
|
+
((this.originCursor % this.originPool.length) + this.originPool.length) %
|
|
337
|
+
this.originPool.length;
|
|
338
|
+
return [...this.originPool.slice(idx), ...this.originPool.slice(0, idx)];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private async sendOne(rec: OutboxRecord): Promise<void> {
|
|
342
|
+
const pathFn = rec.kind === "install" ? installUrl : collectUrl;
|
|
343
|
+
let lastRetryAfter: number | undefined;
|
|
344
|
+
|
|
345
|
+
for (const origin of this.originsInOrder()) {
|
|
346
|
+
const url = pathFn(origin);
|
|
347
|
+
const idempotencyForRequest =
|
|
348
|
+
rec.kind === "collect"
|
|
349
|
+
? (this.collectIdempotencyKey(rec.payload) ?? rec.id)
|
|
350
|
+
: rec.id;
|
|
351
|
+
const res = await postJson(
|
|
352
|
+
url,
|
|
353
|
+
rec.payload,
|
|
354
|
+
this.cfg.requestTimeoutMs,
|
|
355
|
+
idempotencyForRequest,
|
|
356
|
+
);
|
|
357
|
+
if (!res.ok) {
|
|
358
|
+
log(this.cfg.debug, "post transport fail", rec.id, origin, res.kind);
|
|
359
|
+
if (res.retryAfterMs !== undefined) lastRetryAfter = res.retryAfterMs;
|
|
360
|
+
if (res.kind === "network" || res.kind === "timeout") {
|
|
361
|
+
await this.reportDomainUnreachable(origin, res.kind);
|
|
362
|
+
this.rotateCursorAfter(origin);
|
|
363
|
+
}
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (res.retryAfterMs !== undefined) lastRetryAfter = res.retryAfterMs;
|
|
367
|
+
|
|
368
|
+
if (res.status >= 200 && res.status < 300) {
|
|
369
|
+
if (rec.kind === "collect") {
|
|
370
|
+
this.captureVisitorFromCollectAck(res.bodyJson);
|
|
371
|
+
}
|
|
372
|
+
this.setCursorToOrigin(origin);
|
|
373
|
+
await this.refreshEndpointPool(origin);
|
|
374
|
+
await this.removeRecord(rec.id);
|
|
375
|
+
log(this.cfg.debug, "sent", rec.id, res.status, origin);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (isNonRetryableStatus(res.status)) {
|
|
379
|
+
log(this.cfg.debug, "drop non-retryable", rec.id, res.status);
|
|
380
|
+
await this.removeRecord(rec.id);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (isRetryableStatus(res.status)) {
|
|
384
|
+
log(
|
|
385
|
+
this.cfg.debug,
|
|
386
|
+
"retryable http, try next origin",
|
|
387
|
+
rec.id,
|
|
388
|
+
res.status,
|
|
389
|
+
origin,
|
|
390
|
+
);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
log(this.cfg.debug, "drop unexpected status", rec.id, res.status);
|
|
394
|
+
await this.removeRecord(rec.id);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
await this.scheduleRetry(rec, lastRetryAfter);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private async scheduleRetry(
|
|
402
|
+
rec: OutboxRecord,
|
|
403
|
+
retryAfterMs?: number,
|
|
404
|
+
): Promise<void> {
|
|
405
|
+
const nextAttempt = rec.attempts + 1;
|
|
406
|
+
if (nextAttempt >= this.cfg.maxAttempts) {
|
|
407
|
+
log(this.cfg.debug, "max attempts, dropping", rec.id);
|
|
408
|
+
await this.removeRecord(rec.id);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const delay =
|
|
412
|
+
retryAfterMs !== undefined && retryAfterMs > 0
|
|
413
|
+
? retryAfterMs
|
|
414
|
+
: backoffMs(rec.attempts);
|
|
415
|
+
rec.attempts = nextAttempt;
|
|
416
|
+
rec.nextAttemptAt = Date.now() + delay;
|
|
417
|
+
await this.updateRecord(rec);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private originHost(origin: string): string {
|
|
421
|
+
try {
|
|
422
|
+
return new URL(origin).hostname.toLowerCase();
|
|
423
|
+
} catch {
|
|
424
|
+
return origin.toLowerCase();
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private setCursorToOrigin(origin: string): void {
|
|
429
|
+
const idx = this.originPool.indexOf(origin);
|
|
430
|
+
if (idx >= 0) this.originCursor = idx;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private rotateCursorAfter(origin: string): void {
|
|
434
|
+
const idx = this.originPool.indexOf(origin);
|
|
435
|
+
if (idx >= 0 && this.originPool.length > 1) {
|
|
436
|
+
this.originCursor = (idx + 1) % this.originPool.length;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private async reportDomainUnreachable(
|
|
441
|
+
origin: string,
|
|
442
|
+
kind: "network" | "timeout",
|
|
443
|
+
): Promise<void> {
|
|
444
|
+
const host = this.originHost(origin);
|
|
445
|
+
const now = Date.now();
|
|
446
|
+
const lastAt = this.lastDomainHealthReportAt.get(host) ?? 0;
|
|
447
|
+
if (now - lastAt < this.cfg.domainHealthReportMinIntervalMs) return;
|
|
448
|
+
this.lastDomainHealthReportAt.set(host, now);
|
|
449
|
+
const reason = kind === "timeout" ? "timeout" : "network";
|
|
450
|
+
const payload = JSON.stringify({
|
|
451
|
+
domain: host,
|
|
452
|
+
sdk: "web",
|
|
453
|
+
reason,
|
|
454
|
+
sdkVersion: "web-v1",
|
|
455
|
+
unreachableCount: 1,
|
|
456
|
+
totalCount: 1,
|
|
457
|
+
timestamp: new Date(now).toISOString(),
|
|
458
|
+
});
|
|
459
|
+
await postJson(
|
|
460
|
+
`${origin}/v1/sdk/domain-health/report`,
|
|
461
|
+
payload,
|
|
462
|
+
this.cfg.requestTimeoutMs,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private async refreshEndpointPool(origin: string): Promise<void> {
|
|
467
|
+
const now = Date.now();
|
|
468
|
+
if (
|
|
469
|
+
now - this.lastEndpointListRefreshAt <
|
|
470
|
+
this.cfg.endpointListTtlSec * 1000
|
|
471
|
+
)
|
|
472
|
+
return;
|
|
473
|
+
const ctrl = new AbortController();
|
|
474
|
+
const timeout = setTimeout(() => ctrl.abort(), this.cfg.requestTimeoutMs);
|
|
475
|
+
try {
|
|
476
|
+
const res = await fetch(`${origin}/v1/sdk/ingestion-hosts`, {
|
|
477
|
+
method: "GET",
|
|
478
|
+
signal: ctrl.signal,
|
|
479
|
+
});
|
|
480
|
+
if (!res.ok) return;
|
|
481
|
+
const body = (await res.json()) as { hosts?: unknown };
|
|
482
|
+
if (!Array.isArray(body.hosts)) return;
|
|
483
|
+
const nextOrigins = body.hosts
|
|
484
|
+
.map((h) =>
|
|
485
|
+
String(h || "")
|
|
486
|
+
.trim()
|
|
487
|
+
.toLowerCase(),
|
|
488
|
+
)
|
|
489
|
+
.filter(Boolean)
|
|
490
|
+
.map((h) => `https://${h}`);
|
|
491
|
+
if (!nextOrigins.length) return;
|
|
492
|
+
this.originPool = [...new Set(nextOrigins)];
|
|
493
|
+
this.originCursor = 0;
|
|
494
|
+
this.lastEndpointListRefreshAt = now;
|
|
495
|
+
} catch {
|
|
496
|
+
// best effort only
|
|
497
|
+
} finally {
|
|
498
|
+
clearTimeout(timeout);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private collectIdempotencyKey(payload: string): string | undefined {
|
|
503
|
+
try {
|
|
504
|
+
const parsed = JSON.parse(payload) as { idempotencyKey?: unknown };
|
|
505
|
+
const v =
|
|
506
|
+
typeof parsed?.idempotencyKey === "string"
|
|
507
|
+
? parsed.idempotencyKey.trim()
|
|
508
|
+
: "";
|
|
509
|
+
return v || undefined;
|
|
510
|
+
} catch {
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private captureVisitorFromCollectAck(body: unknown): void {
|
|
516
|
+
if (!body || typeof body !== "object") return;
|
|
517
|
+
const visitorIdRaw =
|
|
518
|
+
"visitorId" in body ? (body as { visitorId?: unknown }).visitorId : "";
|
|
519
|
+
const visitorId =
|
|
520
|
+
typeof visitorIdRaw === "string" ? visitorIdRaw.trim() : "";
|
|
521
|
+
if (!visitorId) return;
|
|
522
|
+
this.context.visitorId = visitorId;
|
|
523
|
+
writeStoredVisitorId(visitorId);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
let singleton: YTrackingWebClient | null = null;
|
|
528
|
+
|
|
529
|
+
export function init(cfg: InitConfig): YTrackingWebClient {
|
|
530
|
+
if (typeof window === "undefined") {
|
|
531
|
+
throw new Error("YTrackingWeb: browser only");
|
|
532
|
+
}
|
|
533
|
+
singleton?.shutdown();
|
|
534
|
+
singleton = new YTrackingWebClient(cfg);
|
|
535
|
+
void singleton.start();
|
|
536
|
+
return singleton;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function getClient(): YTrackingWebClient | null {
|
|
540
|
+
return singleton;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export async function track(
|
|
544
|
+
eventType: string,
|
|
545
|
+
options?: { props?: Record<string, unknown> },
|
|
546
|
+
): Promise<void> {
|
|
547
|
+
if (!singleton) throw new Error("YTrackingWeb: call init() first");
|
|
548
|
+
return singleton.track(eventType, options);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export async function trackPageView(): Promise<void> {
|
|
552
|
+
if (!singleton) throw new Error("YTrackingWeb: call init() first");
|
|
553
|
+
return singleton.trackPageView();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export async function flush(): Promise<void> {
|
|
557
|
+
if (singleton) await singleton.flush();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function shutdown(): void {
|
|
561
|
+
singleton?.shutdown();
|
|
562
|
+
singleton = null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export async function install(opts?: { clickId?: string }): Promise<void> {
|
|
566
|
+
if (!singleton) throw new Error("YTrackingWeb: call init() first");
|
|
567
|
+
return singleton.install(opts);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export function setContext(partial: {
|
|
571
|
+
visitorId?: string;
|
|
572
|
+
sessionId?: string;
|
|
573
|
+
clickId?: string;
|
|
574
|
+
campaignId?: string;
|
|
575
|
+
appId?: string;
|
|
576
|
+
}): void {
|
|
577
|
+
singleton?.setContext(partial);
|
|
578
|
+
}
|
package/src/idb.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { OutboxRecord } from "./types";
|
|
2
|
+
|
|
3
|
+
const DB_NAME = "ytracking_web_sdk";
|
|
4
|
+
const DB_VERSION = 2;
|
|
5
|
+
const STORE = "outbox";
|
|
6
|
+
|
|
7
|
+
export async function openOutboxDb(): Promise<IDBDatabase | null> {
|
|
8
|
+
if (typeof indexedDB === "undefined") return null;
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
11
|
+
req.onerror = () => resolve(null);
|
|
12
|
+
req.onsuccess = () => resolve(req.result);
|
|
13
|
+
req.onupgradeneeded = () => {
|
|
14
|
+
const db = req.result;
|
|
15
|
+
if (!db.objectStoreNames.contains(STORE)) {
|
|
16
|
+
const s = db.createObjectStore(STORE, { keyPath: "id" });
|
|
17
|
+
s.createIndex("nextAttemptAt", "nextAttemptAt", { unique: false });
|
|
18
|
+
} else {
|
|
19
|
+
const tx = req.transaction;
|
|
20
|
+
const s = tx?.objectStore(STORE);
|
|
21
|
+
if (s && !s.indexNames.contains("nextAttemptAt")) {
|
|
22
|
+
s.createIndex("nextAttemptAt", "nextAttemptAt", { unique: false });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function idbAdd(
|
|
30
|
+
db: IDBDatabase,
|
|
31
|
+
record: OutboxRecord,
|
|
32
|
+
): Promise<boolean> {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const tx = db.transaction(STORE, "readwrite");
|
|
35
|
+
tx.oncomplete = () => resolve(true);
|
|
36
|
+
tx.onerror = () => resolve(false);
|
|
37
|
+
tx.onabort = () => resolve(false);
|
|
38
|
+
tx.objectStore(STORE).add(record);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function idbGetAll(db: IDBDatabase): Promise<OutboxRecord[]> {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const tx = db.transaction(STORE, "readonly");
|
|
45
|
+
const req = tx.objectStore(STORE).getAll();
|
|
46
|
+
req.onsuccess = () => resolve((req.result as OutboxRecord[]) || []);
|
|
47
|
+
req.onerror = () => resolve([]);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function idbCount(db: IDBDatabase): Promise<number> {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const tx = db.transaction(STORE, "readonly");
|
|
54
|
+
const req = tx.objectStore(STORE).count();
|
|
55
|
+
req.onsuccess = () => resolve(Number(req.result ?? 0));
|
|
56
|
+
req.onerror = () => resolve(0);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function idbGetDue(
|
|
61
|
+
db: IDBDatabase,
|
|
62
|
+
nowMs: number,
|
|
63
|
+
limit: number,
|
|
64
|
+
): Promise<OutboxRecord[]> {
|
|
65
|
+
if (limit <= 0) return [];
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
const tx = db.transaction(STORE, "readonly");
|
|
68
|
+
const out: OutboxRecord[] = [];
|
|
69
|
+
const idx = tx.objectStore(STORE).index("nextAttemptAt");
|
|
70
|
+
const req = idx.openCursor(IDBKeyRange.upperBound(nowMs));
|
|
71
|
+
req.onsuccess = () => {
|
|
72
|
+
const cur = req.result;
|
|
73
|
+
if (!cur || out.length >= limit) {
|
|
74
|
+
resolve(out);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
out.push(cur.value as OutboxRecord);
|
|
78
|
+
cur.continue();
|
|
79
|
+
};
|
|
80
|
+
req.onerror = () => resolve([]);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function idbDelete(db: IDBDatabase, id: string): Promise<void> {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const tx = db.transaction(STORE, "readwrite");
|
|
87
|
+
tx.oncomplete = () => resolve();
|
|
88
|
+
tx.onerror = () => reject(tx.error);
|
|
89
|
+
tx.objectStore(STORE).delete(id);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function idbPut(
|
|
94
|
+
db: IDBDatabase,
|
|
95
|
+
record: OutboxRecord,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const tx = db.transaction(STORE, "readwrite");
|
|
99
|
+
tx.oncomplete = () => resolve();
|
|
100
|
+
tx.onerror = () => reject(tx.error);
|
|
101
|
+
tx.objectStore(STORE).put(record);
|
|
102
|
+
});
|
|
103
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type { InitConfig, OutboxRecord, OutboxKind } from "./types";
|
|
2
|
+
export {
|
|
3
|
+
backoffMs,
|
|
4
|
+
eventPriority,
|
|
5
|
+
isNonRetryableStatus,
|
|
6
|
+
isRetryableStatus,
|
|
7
|
+
} from "./retry";
|
|
8
|
+
export { buildCollectEnvelope, getOrCreateSessionId } from "./payload";
|
|
9
|
+
export {
|
|
10
|
+
init,
|
|
11
|
+
track,
|
|
12
|
+
trackPageView,
|
|
13
|
+
flush,
|
|
14
|
+
shutdown,
|
|
15
|
+
install,
|
|
16
|
+
setContext,
|
|
17
|
+
getClient,
|
|
18
|
+
YTrackingWebClient,
|
|
19
|
+
} from "./client";
|
package/src/payload.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
const SESSION_KEY = "_ytsid";
|
|
2
|
+
const VISITOR_KEY = "yt_vid_v1";
|
|
3
|
+
|
|
4
|
+
export function getOrCreateSessionId(): string | undefined {
|
|
5
|
+
try {
|
|
6
|
+
const existing = localStorage.getItem(SESSION_KEY);
|
|
7
|
+
if (existing) return existing;
|
|
8
|
+
const sid =
|
|
9
|
+
typeof crypto !== "undefined" && crypto.randomUUID
|
|
10
|
+
? crypto.randomUUID()
|
|
11
|
+
: `sess_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
12
|
+
localStorage.setItem(SESSION_KEY, sid);
|
|
13
|
+
return sid;
|
|
14
|
+
} catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readStoredVisitorId(): string | undefined {
|
|
20
|
+
try {
|
|
21
|
+
const v = localStorage.getItem(VISITOR_KEY)?.trim() ?? "";
|
|
22
|
+
return v || undefined;
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function writeStoredVisitorId(visitorId: string | undefined): void {
|
|
29
|
+
if (!visitorId) return;
|
|
30
|
+
try {
|
|
31
|
+
localStorage.setItem(VISITOR_KEY, visitorId.trim());
|
|
32
|
+
} catch {
|
|
33
|
+
// best effort
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hashDjb2(input: string): string {
|
|
38
|
+
let h = 5381;
|
|
39
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
40
|
+
h = (h * 33) ^ input.charCodeAt(i);
|
|
41
|
+
}
|
|
42
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildSoftFingerprintVisitorId(): string | undefined {
|
|
46
|
+
try {
|
|
47
|
+
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
|
|
48
|
+
const language = typeof navigator !== "undefined" ? navigator.language : "";
|
|
49
|
+
const timezone =
|
|
50
|
+
typeof Intl !== "undefined"
|
|
51
|
+
? Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
52
|
+
: "";
|
|
53
|
+
const width = typeof screen !== "undefined" ? String(screen.width) : "";
|
|
54
|
+
const height = typeof screen !== "undefined" ? String(screen.height) : "";
|
|
55
|
+
const seed = [ua, language, timezone, width, height].join("|");
|
|
56
|
+
if (!seed.replace(/\|/g, "").trim()) return undefined;
|
|
57
|
+
return `fp_${hashDjb2(seed)}`;
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function queryParams(): Record<string, string> {
|
|
64
|
+
const out: Record<string, string> = {};
|
|
65
|
+
try {
|
|
66
|
+
const sp = new URLSearchParams(location.search);
|
|
67
|
+
sp.forEach((v, k) => {
|
|
68
|
+
out[k] = v;
|
|
69
|
+
});
|
|
70
|
+
} catch {
|
|
71
|
+
/* ignore */
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function coarseDevice(ua: string): {
|
|
77
|
+
type: string;
|
|
78
|
+
os: string | null;
|
|
79
|
+
browser: string | null;
|
|
80
|
+
} {
|
|
81
|
+
const u = ua.toLowerCase();
|
|
82
|
+
const type = /tablet|ipad/.test(u)
|
|
83
|
+
? "tablet"
|
|
84
|
+
: /mobile|iphone|android/.test(u)
|
|
85
|
+
? "mobile"
|
|
86
|
+
: "desktop";
|
|
87
|
+
const os = /iphone|ipad|ipod/.test(u)
|
|
88
|
+
? "iOS"
|
|
89
|
+
: /android/.test(u)
|
|
90
|
+
? "Android"
|
|
91
|
+
: /mac os/.test(u)
|
|
92
|
+
? "macOS"
|
|
93
|
+
: /windows/.test(u)
|
|
94
|
+
? "Windows"
|
|
95
|
+
: null;
|
|
96
|
+
const browser = /edg\//.test(u)
|
|
97
|
+
? "Edge"
|
|
98
|
+
: /chrome/.test(u) && !/edg/.test(u)
|
|
99
|
+
? "Chrome"
|
|
100
|
+
: /safari/.test(u) && !/chrome/.test(u)
|
|
101
|
+
? "Safari"
|
|
102
|
+
: /firefox/.test(u)
|
|
103
|
+
? "Firefox"
|
|
104
|
+
: null;
|
|
105
|
+
return { type, os, browser };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isUuidLike(s: string): boolean {
|
|
109
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
|
110
|
+
s,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type CollectEnvelope = {
|
|
115
|
+
siteId: string;
|
|
116
|
+
/** Same as `event.props._ytSdkEventId` when that value is a UUID; server uses it as `events.id` for retries. */
|
|
117
|
+
idempotencyKey?: string;
|
|
118
|
+
event: {
|
|
119
|
+
type: string;
|
|
120
|
+
timestamp: string;
|
|
121
|
+
sessionId?: string;
|
|
122
|
+
page: { id: string; path: string; title?: string };
|
|
123
|
+
traffic: {
|
|
124
|
+
landingUrl: string;
|
|
125
|
+
referrer?: string;
|
|
126
|
+
query: Record<string, string>;
|
|
127
|
+
};
|
|
128
|
+
device: {
|
|
129
|
+
type: string;
|
|
130
|
+
os?: string;
|
|
131
|
+
browser?: string;
|
|
132
|
+
language?: string;
|
|
133
|
+
timezone?: string;
|
|
134
|
+
screenWidth?: number;
|
|
135
|
+
screenHeight?: number;
|
|
136
|
+
};
|
|
137
|
+
props: Record<string, unknown>;
|
|
138
|
+
visitorId?: string;
|
|
139
|
+
clickId?: string;
|
|
140
|
+
campaignId?: string;
|
|
141
|
+
appId?: string;
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export function buildCollectEnvelope(
|
|
146
|
+
siteId: string,
|
|
147
|
+
eventType: string,
|
|
148
|
+
props: Record<string, unknown>,
|
|
149
|
+
opts?: {
|
|
150
|
+
visitorId?: string;
|
|
151
|
+
sessionId?: string;
|
|
152
|
+
clickId?: string;
|
|
153
|
+
campaignId?: string;
|
|
154
|
+
appId?: string;
|
|
155
|
+
},
|
|
156
|
+
): CollectEnvelope {
|
|
157
|
+
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
|
|
158
|
+
const dev = coarseDevice(ua);
|
|
159
|
+
const sdkEventId =
|
|
160
|
+
typeof crypto !== "undefined" && crypto.randomUUID
|
|
161
|
+
? crypto.randomUUID()
|
|
162
|
+
: `evt_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
163
|
+
|
|
164
|
+
const root: CollectEnvelope = {
|
|
165
|
+
siteId: String(siteId).trim(),
|
|
166
|
+
event: {
|
|
167
|
+
type: eventType,
|
|
168
|
+
timestamp: new Date().toISOString(),
|
|
169
|
+
sessionId: opts?.sessionId?.trim() || getOrCreateSessionId(),
|
|
170
|
+
page: {
|
|
171
|
+
id: typeof location !== "undefined" ? location.pathname || "/" : "/",
|
|
172
|
+
path: typeof location !== "undefined" ? location.pathname || "/" : "/",
|
|
173
|
+
title:
|
|
174
|
+
typeof document !== "undefined"
|
|
175
|
+
? document.title || undefined
|
|
176
|
+
: undefined,
|
|
177
|
+
},
|
|
178
|
+
traffic: {
|
|
179
|
+
landingUrl: typeof location !== "undefined" ? location.href : "",
|
|
180
|
+
referrer:
|
|
181
|
+
typeof document !== "undefined"
|
|
182
|
+
? document.referrer || undefined
|
|
183
|
+
: undefined,
|
|
184
|
+
query: typeof location !== "undefined" ? queryParams() : {},
|
|
185
|
+
},
|
|
186
|
+
device: {
|
|
187
|
+
type: dev.type,
|
|
188
|
+
os: dev.os || undefined,
|
|
189
|
+
browser: dev.browser || undefined,
|
|
190
|
+
language:
|
|
191
|
+
typeof navigator !== "undefined" ? navigator.language : undefined,
|
|
192
|
+
timezone:
|
|
193
|
+
typeof Intl !== "undefined"
|
|
194
|
+
? Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
195
|
+
: undefined,
|
|
196
|
+
screenWidth: typeof screen !== "undefined" ? screen.width : undefined,
|
|
197
|
+
screenHeight: typeof screen !== "undefined" ? screen.height : undefined,
|
|
198
|
+
},
|
|
199
|
+
props: { ...props, _ytSdkEventId: sdkEventId, _ytSdk: "web-v1" },
|
|
200
|
+
visitorId: opts?.visitorId?.trim() || undefined,
|
|
201
|
+
clickId: opts?.clickId?.trim() || undefined,
|
|
202
|
+
campaignId: opts?.campaignId?.trim() || undefined,
|
|
203
|
+
appId: opts?.appId?.trim() || undefined,
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
if (isUuidLike(sdkEventId)) root.idempotencyKey = sdkEventId;
|
|
207
|
+
return root;
|
|
208
|
+
}
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** HTTP statuses that should not be retried (docs/SDK_DESIGN.md §8). */
|
|
2
|
+
export function isNonRetryableStatus(status: number): boolean {
|
|
3
|
+
return status === 400 || status === 401 || status === 403 || status === 404;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function isRetryableStatus(status: number): boolean {
|
|
7
|
+
if (status === 429) return true;
|
|
8
|
+
if (status >= 500 && status <= 599) return true;
|
|
9
|
+
if (status === 522 || status === 523 || status === 525) return true;
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function backoffMs(attemptIndexZeroBased: number): number {
|
|
14
|
+
const table = [2000, 5000, 15000, 30000, 60000];
|
|
15
|
+
const base =
|
|
16
|
+
attemptIndexZeroBased < table.length
|
|
17
|
+
? table[attemptIndexZeroBased]
|
|
18
|
+
: 600000;
|
|
19
|
+
const jitter = base * 0.2 * (Math.random() * 2 - 1);
|
|
20
|
+
return Math.min(Math.max(0, Math.floor(base + jitter)), 600000);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const HIGH = new Set(["download_click", "registration", "payment", "app_open"]);
|
|
24
|
+
const LOW = new Set(["page_view", "scroll", "heartbeat"]);
|
|
25
|
+
|
|
26
|
+
export function eventPriority(eventType: string): number {
|
|
27
|
+
if (HIGH.has(eventType)) return 10;
|
|
28
|
+
if (LOW.has(eventType)) return 1;
|
|
29
|
+
return 5;
|
|
30
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export type PostResult =
|
|
2
|
+
| { ok: true; status: number; retryAfterMs?: number; bodyJson?: unknown }
|
|
3
|
+
| {
|
|
4
|
+
ok: false;
|
|
5
|
+
kind: "network" | "timeout" | "http";
|
|
6
|
+
status?: number;
|
|
7
|
+
retryAfterMs?: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function parseRetryAfter(header: string | null): number | undefined {
|
|
11
|
+
if (!header) return undefined;
|
|
12
|
+
const raw = header.trim();
|
|
13
|
+
const n = Number.parseInt(raw, 10);
|
|
14
|
+
if (Number.isFinite(n) && n >= 0) return n * 1000;
|
|
15
|
+
const atMs = Date.parse(raw);
|
|
16
|
+
if (Number.isFinite(atMs)) {
|
|
17
|
+
return Math.max(0, atMs - Date.now());
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function postJson(
|
|
23
|
+
url: string,
|
|
24
|
+
body: string,
|
|
25
|
+
timeoutMs: number,
|
|
26
|
+
idempotencyKey?: string,
|
|
27
|
+
): Promise<PostResult> {
|
|
28
|
+
const ctrl = new AbortController();
|
|
29
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
30
|
+
try {
|
|
31
|
+
const hdrs: Record<string, string> = { "Content-Type": "application/json" };
|
|
32
|
+
if (idempotencyKey) hdrs["Idempotency-Key"] = idempotencyKey;
|
|
33
|
+
const res = await fetch(url, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: hdrs,
|
|
36
|
+
body,
|
|
37
|
+
credentials: "include",
|
|
38
|
+
signal: ctrl.signal,
|
|
39
|
+
keepalive: true,
|
|
40
|
+
});
|
|
41
|
+
clearTimeout(t);
|
|
42
|
+
const retryAfterMs = parseRetryAfter(res.headers.get("Retry-After"));
|
|
43
|
+
let bodyJson: unknown;
|
|
44
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
45
|
+
if (contentType.includes("application/json")) {
|
|
46
|
+
try {
|
|
47
|
+
bodyJson = await res.clone().json();
|
|
48
|
+
} catch {
|
|
49
|
+
bodyJson = undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (retryAfterMs !== undefined) {
|
|
53
|
+
return { ok: true, status: res.status, retryAfterMs, bodyJson };
|
|
54
|
+
}
|
|
55
|
+
return { ok: true, status: res.status, bodyJson };
|
|
56
|
+
} catch (e) {
|
|
57
|
+
clearTimeout(t);
|
|
58
|
+
const name =
|
|
59
|
+
e && typeof e === "object" && "name" in e
|
|
60
|
+
? String((e as Error).name)
|
|
61
|
+
: "";
|
|
62
|
+
if (name === "AbortError") return { ok: false, kind: "timeout" };
|
|
63
|
+
return { ok: false, kind: "network" };
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type OutboxKind = "collect" | "install";
|
|
2
|
+
|
|
3
|
+
export type OutboxRecord = {
|
|
4
|
+
id: string;
|
|
5
|
+
kind: OutboxKind;
|
|
6
|
+
payload: string;
|
|
7
|
+
attempts: number;
|
|
8
|
+
nextAttemptAt: number;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
priority: number;
|
|
11
|
+
eventType: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type InitConfig = {
|
|
15
|
+
siteId: string;
|
|
16
|
+
baseUrls: string[];
|
|
17
|
+
fallbackBaseUrls?: string[];
|
|
18
|
+
debug?: boolean;
|
|
19
|
+
enableVisitorFallbackFingerprint?: boolean;
|
|
20
|
+
maxQueueSize?: number;
|
|
21
|
+
flushIntervalMs?: number;
|
|
22
|
+
requestTimeoutMs?: number;
|
|
23
|
+
batchSize?: number;
|
|
24
|
+
flushConcurrency?: number;
|
|
25
|
+
maxAttempts?: number;
|
|
26
|
+
endpointListTtlSec?: number;
|
|
27
|
+
domainHealthReportMinIntervalMs?: number;
|
|
28
|
+
};
|