ytracking-web 0.1.2 → 0.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 CHANGED
@@ -28,33 +28,59 @@ npm install ytracking-web
28
28
 
29
29
  ## 使用(IIFE)
30
30
 
31
+ **最少程式(腳本與 API 同源、後台未強制 collect 金鑰時):** 只需 `siteId`;SDK 會從 `<script src="…ytracking-web.js">` 推斷 ingest origin,並在啟動後自動送一次 `pageview`。
32
+
31
33
  ```html
32
34
  <script src="https://你的網域/sdk/ytracking-web.js"></script>
35
+ <script>
36
+ YTrackingWeb.init({ siteId: "站點-UUID" });
37
+ </script>
38
+ ```
39
+
40
+ **明確指定 API 網址或備援、或帶 SDK key(與行動端一致):**
41
+
42
+ ```html
43
+ <script src="https://cdn.example.com/sdk/ytracking-web.js"></script>
33
44
  <script>
34
45
  YTrackingWeb.init({
35
46
  siteId: "站點-UUID",
36
- baseUrls: ["https://你的網域"],
37
- fallbackBaseUrls: [],
38
- endpointListTtlSec: 300,
39
- domainHealthReportMinIntervalMs: 300000,
40
- debug: false,
47
+ baseUrl: "https://ingest.example.com",
48
+ fallbackBaseUrls: ["https://ingest-backup.example.com"],
49
+ sdkKey: "yt_sdk_…",
41
50
  });
42
- YTrackingWeb.trackPageView();
43
51
  </script>
44
52
  ```
45
53
 
54
+ 若腳本託管在 CDN 而 API 在另一網域,**必須**設定 `baseUrl` 或 `baseUrls`(不可依賴推斷)。
55
+ 僅追蹤 SPA 路由、不要自動首屏 `pageview` 時:加 **`autoTrackPageView: false`**,再自行呼叫 `pageView()`(舊名 `trackPageView()`)。
56
+
57
+ ### 0.2.0 行為變更(升級自 0.1.x)
58
+
59
+ - 預設 **`autoTrackPageView: true`**:若你原本在 `init` 後又手動呼叫一次 `pageView()`(或 `trackPageView()`),首屏可能重複計數 — 請刪除多餘呼叫,或設 `autoTrackPageView: false`。
60
+ - `baseUrls` 改為可選:可只用 **`baseUrl`**(字串),或与 `baseUrls` 合併(`baseUrl` 優先)。
61
+
62
+ ### 0.3.0
63
+
64
+ - **套件/API 行為**:與 0.2.0 相同(minor 發佈:對外文件、`external-demo.html` 的 **`tracker=web`** 驗收路徑與靜態 bundle 對齊)。
65
+ - **整合測試**:Production/本機可於 **`/external-demo.html?siteId=…&tracker=web`** 驗證 Web SDK(見 repo **`docs/SITE_AND_EMBED.md`**)。
66
+
46
67
  ## API(`YTrackingWeb`)
47
68
 
48
- - `init(config)` — 必填 `siteId`、`baseUrls`(無尾階 `/` 之 origin)
69
+ - `init(config)` — 必填 **`siteId`**
70
+ - **`baseUrl`**:單一 ingest origin(無尾階 `/`);與 **`baseUrls`** 可併用(順序:先 `baseUrl` 再 `baseUrls`)
71
+ - **`baseUrls`**:ingest origins 陣列;若與 `baseUrl` 皆省略,會嘗試從頁面上最後一個 **`…ytracking-web(.min).js`** 的 `<script src>` 推斷 origin(腳本須與 API 同源才正確)
72
+ - **`sdkKey`**:若設定,所有 POST(collect、install、attribution token、domain-health report)會帶 **`X-YT-Sdk-Key`**
73
+ - **`autoTrackPageView`**:預設 **`true`**,`start()` 後自動 enqueue 一次 `pageview`;設 `false` 則完全自行呼叫 `pageView`/`event`
49
74
  - `enableVisitorFallbackFingerprint`:可選(預設 `false`),僅在無 cookie + 無 localStorage visitor 時以低熵指紋產生暫時 visitor seed
50
75
  - `maxQueueSize`:本地 queue 容量上限(預設 `500`),超量時淘汰低優先且最舊事件
51
76
  - `batchSize`:單次 flush 最多取件數(預設 `8`)
52
77
  - `flushConcurrency`:單次 flush 內平行送件 worker 數(預設 `2`,最小 `1`)
53
78
  - `endpointListTtlSec`:成功送達後重新拉取 `/v1/sdk/ingestion-hosts` 的最短間隔(秒,預設 300)
54
79
  - `domainHealthReportMinIntervalMs`:同一 domain 不可達回報最短間隔(毫秒,預設 300000)
55
- - `track(type, { props })` — 對應 `POST /v1/collect` envelope
56
- - `trackPageView()` — `page_view`
57
- - `install({ clickId? })` — 內嵌 **`POST /v1/install`**(無 `appId` 之 embed 形態)
80
+ - `event(type, { props })` — 對應 `POST /v1/collect` envelope
81
+ - `pageView()` — `pageview`(`trackPageView()` 仍可用)
82
+ - `paid({ props? })` — `paid`
83
+ - `install({ clickId? })` — 經佇列送出 **`POST /v1/install`**(與 `yt-embed` 之 `sendInstall` 一致)
58
84
  - `issueAttributionToken({ clickId?, ttlSeconds?, campaignId?, appId?, deferredContext? })` — 同步 **`POST /v1/attribution/token`**(不入佇列);沿用 `setContext` 之 visitor/session/click 等
59
85
  - `flush()` — 手動送出佇列
60
86
  - `setContext({ visitorId, sessionId, clickId, campaignId, appId })` — 後續事件帶入
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ytracking-web",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Browser SDK for YTracking collect/install (IndexedDB queue, retry, multi-host)",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/client.ts CHANGED
@@ -37,12 +37,51 @@ function normalizeOrigins(urls: string[]): string[] {
37
37
  return out;
38
38
  }
39
39
 
40
+ /** When `baseUrl` / `baseUrls` omitted, use the origin of the last ytracking-web script tag. */
41
+ function inferBaseUrlFromLastYTrackingScript(): string | undefined {
42
+ if (typeof document === "undefined") return undefined;
43
+ const scripts = document.getElementsByTagName("script");
44
+ for (let i = scripts.length - 1; i >= 0; i -= 1) {
45
+ const el = scripts.item(i);
46
+ if (!el || el.nodeName !== "SCRIPT") continue;
47
+ const src = (el as HTMLScriptElement).src;
48
+ if (!src) continue;
49
+ try {
50
+ const baseHref =
51
+ typeof location !== "undefined" && location.href
52
+ ? location.href
53
+ : "https://invalid.invalid/";
54
+ const u = new URL(src, baseHref);
55
+ if (/ytracking-web(\.min)?\.js([?#]|$)/i.test(u.pathname)) {
56
+ return u.origin;
57
+ }
58
+ } catch {
59
+ /* ignore */
60
+ }
61
+ }
62
+ return undefined;
63
+ }
64
+
65
+ function mergeBaseOrigins(cfg: InitConfig): string[] {
66
+ const parts: string[] = [];
67
+ if (cfg.baseUrl) parts.push(cfg.baseUrl);
68
+ if (cfg.baseUrls?.length) parts.push(...cfg.baseUrls);
69
+ let base = normalizeOrigins(parts);
70
+ if (base.length === 0) {
71
+ const inferred = inferBaseUrlFromLastYTrackingScript();
72
+ if (inferred) base = [inferred];
73
+ }
74
+ return [...new Set(base)];
75
+ }
76
+
40
77
  function collectUrl(origin: string): string {
41
78
  return `${origin}/v1/collect`;
42
79
  }
43
80
 
44
- function installUrl(origin: string): string {
45
- return `${origin}/v1/install`;
81
+ function randomUuid(): string {
82
+ return typeof crypto !== "undefined" && crypto.randomUUID
83
+ ? crypto.randomUUID()
84
+ : `id_${Date.now()}_${Math.random().toString(36).slice(2)}`;
46
85
  }
47
86
 
48
87
  function attributionTokenUrl(origin: string): string {
@@ -51,7 +90,10 @@ function attributionTokenUrl(origin: string): string {
51
90
 
52
91
  export class YTrackingWebClient {
53
92
  private cfg: Required<
54
- Pick<InitConfig, "siteId" | "baseUrls"> & {
93
+ Pick<InitConfig, "siteId"> & {
94
+ baseUrls: string[];
95
+ sdkKey: string | undefined;
96
+ autoTrackPageView: boolean;
55
97
  fallbackBaseUrls: string[];
56
98
  debug: boolean;
57
99
  enableVisitorFallbackFingerprint: boolean;
@@ -83,15 +125,18 @@ export class YTrackingWebClient {
83
125
  } = {};
84
126
 
85
127
  constructor(cfg: InitConfig) {
86
- const base = normalizeOrigins(cfg.baseUrls);
128
+ const base = mergeBaseOrigins(cfg);
87
129
  if (!cfg.siteId?.trim() || base.length === 0) {
88
130
  throw new Error(
89
- "YTrackingWeb: siteId and at least one baseUrl are required",
131
+ "YTrackingWeb: siteId and at least one ingest origin are required (set baseUrl or baseUrls, or load the SDK from your API host so the script origin can be inferred)",
90
132
  );
91
133
  }
134
+ const sk = cfg.sdkKey?.trim();
92
135
  this.cfg = {
93
136
  siteId: cfg.siteId.trim(),
94
137
  baseUrls: base,
138
+ sdkKey: sk || undefined,
139
+ autoTrackPageView: cfg.autoTrackPageView !== false,
95
140
  fallbackBaseUrls: normalizeOrigins(cfg.fallbackBaseUrls ?? []),
96
141
  debug: !!cfg.debug,
97
142
  enableVisitorFallbackFingerprint: !!cfg.enableVisitorFallbackFingerprint,
@@ -110,6 +155,12 @@ export class YTrackingWebClient {
110
155
  ];
111
156
  }
112
157
 
158
+ private sdkHeaders(): Record<string, string> | undefined {
159
+ const k = this.cfg.sdkKey;
160
+ if (!k) return undefined;
161
+ return { "X-YT-Sdk-Key": k };
162
+ }
163
+
113
164
  setContext(partial: {
114
165
  visitorId?: string;
115
166
  sessionId?: string;
@@ -150,6 +201,9 @@ export class YTrackingWebClient {
150
201
  document.addEventListener("visibilitychange", this.visibilityHandler);
151
202
  }
152
203
  void this.flush();
204
+ if (this.cfg.autoTrackPageView) {
205
+ void this.pageView();
206
+ }
153
207
  }
154
208
 
155
209
  shutdown(): void {
@@ -167,7 +221,7 @@ export class YTrackingWebClient {
167
221
  this.visibilityHandler = null;
168
222
  }
169
223
 
170
- async track(
224
+ async event(
171
225
  eventType: string,
172
226
  options?: { props?: Record<string, unknown> },
173
227
  ): Promise<void> {
@@ -188,14 +242,41 @@ export class YTrackingWebClient {
188
242
  });
189
243
  }
190
244
 
191
- async trackPageView(): Promise<void> {
192
- return this.track("page_view", {});
245
+ async pageView(): Promise<void> {
246
+ return this.event("pageview", {});
193
247
  }
194
248
 
195
- async install(opts?: { clickId?: string }): Promise<void> {
196
- const body: Record<string, string> = { siteId: this.cfg.siteId };
249
+ async paid(options?: { props?: Record<string, unknown> }): Promise<void> {
250
+ return this.event("paid", options);
251
+ }
252
+
253
+ async install(opts?: {
254
+ clickId?: string;
255
+ installId?: string;
256
+ status?: "first_open" | "install_complete";
257
+ occurredAt?: string;
258
+ props?: Record<string, unknown>;
259
+ }): Promise<void> {
260
+ const occurredAt = opts?.occurredAt?.trim() || new Date().toISOString();
261
+ const installId = opts?.installId?.trim() || randomUuid();
262
+ const status = opts?.status?.trim() || "first_open";
263
+ const event: Record<string, unknown> = {
264
+ type: "install",
265
+ timestamp: occurredAt,
266
+ occurredAt,
267
+ installId,
268
+ status,
269
+ };
197
270
  const cid = opts?.clickId?.trim() || this.context.clickId?.trim();
198
- if (cid) body.clickId = cid;
271
+ if (cid) event.clickId = cid;
272
+ const visitorId = this.context.visitorId?.trim();
273
+ if (visitorId) event.visitorId = visitorId;
274
+ const appId = this.context.appId?.trim();
275
+ if (appId) event.appId = appId;
276
+ if (opts?.props && typeof opts.props === "object") {
277
+ event.props = opts.props;
278
+ }
279
+ const body = { siteId: this.cfg.siteId, event };
199
280
  await this.enqueue({
200
281
  kind: "install",
201
282
  payload: JSON.stringify(body),
@@ -244,6 +325,7 @@ export class YTrackingWebClient {
244
325
  payload,
245
326
  this.cfg.requestTimeoutMs,
246
327
  undefined,
328
+ this.sdkHeaders(),
247
329
  );
248
330
  if (!res.ok) {
249
331
  log(
@@ -442,7 +524,7 @@ export class YTrackingWebClient {
442
524
  }
443
525
 
444
526
  private async sendOne(rec: OutboxRecord): Promise<void> {
445
- const pathFn = rec.kind === "install" ? installUrl : collectUrl;
527
+ const pathFn = collectUrl;
446
528
  let lastRetryAfter: number | undefined;
447
529
 
448
530
  for (const origin of this.originsInOrder()) {
@@ -456,6 +538,7 @@ export class YTrackingWebClient {
456
538
  rec.payload,
457
539
  this.cfg.requestTimeoutMs,
458
540
  idempotencyForRequest,
541
+ this.sdkHeaders(),
459
542
  );
460
543
  if (!res.ok) {
461
544
  log(this.cfg.debug, "post transport fail", rec.id, origin, res.kind);
@@ -563,6 +646,8 @@ export class YTrackingWebClient {
563
646
  `${origin}/v1/sdk/domain-health/report`,
564
647
  payload,
565
648
  this.cfg.requestTimeoutMs,
649
+ undefined,
650
+ this.sdkHeaders(),
566
651
  );
567
652
  }
568
653
 
@@ -648,12 +733,12 @@ export async function track(
648
733
  options?: { props?: Record<string, unknown> },
649
734
  ): Promise<void> {
650
735
  if (!singleton) throw new Error("YTrackingWeb: call init() first");
651
- return singleton.track(eventType, options);
736
+ return singleton.event(eventType, options);
652
737
  }
653
738
 
654
739
  export async function trackPageView(): Promise<void> {
655
740
  if (!singleton) throw new Error("YTrackingWeb: call init() first");
656
- return singleton.trackPageView();
741
+ return singleton.pageView();
657
742
  }
658
743
 
659
744
  export async function flush(): Promise<void> {
@@ -665,11 +750,37 @@ export function shutdown(): void {
665
750
  singleton = null;
666
751
  }
667
752
 
668
- export async function install(opts?: { clickId?: string }): Promise<void> {
753
+ export async function install(opts?: {
754
+ clickId?: string;
755
+ installId?: string;
756
+ status?: "first_open" | "install_complete";
757
+ occurredAt?: string;
758
+ props?: Record<string, unknown>;
759
+ }): Promise<void> {
669
760
  if (!singleton) throw new Error("YTrackingWeb: call init() first");
670
761
  return singleton.install(opts);
671
762
  }
672
763
 
764
+ export async function event(
765
+ eventType: string,
766
+ options?: { props?: Record<string, unknown> },
767
+ ): Promise<void> {
768
+ if (!singleton) throw new Error("YTrackingWeb: call init() first");
769
+ return singleton.event(eventType, options);
770
+ }
771
+
772
+ export async function pageView(): Promise<void> {
773
+ if (!singleton) throw new Error("YTrackingWeb: call init() first");
774
+ return singleton.pageView();
775
+ }
776
+
777
+ export async function paid(options?: {
778
+ props?: Record<string, unknown>;
779
+ }): Promise<void> {
780
+ if (!singleton) throw new Error("YTrackingWeb: call init() first");
781
+ return singleton.paid(options);
782
+ }
783
+
673
784
  export async function issueAttributionToken(opts?: {
674
785
  clickId?: string;
675
786
  ttlSeconds?: number;
package/src/index.ts CHANGED
@@ -8,6 +8,9 @@ export {
8
8
  export { buildCollectEnvelope, getOrCreateSessionId } from "./payload";
9
9
  export {
10
10
  init,
11
+ event,
12
+ pageView,
13
+ paid,
11
14
  track,
12
15
  trackPageView,
13
16
  flush,
package/src/retry.ts CHANGED
@@ -20,8 +20,14 @@ export function backoffMs(attemptIndexZeroBased: number): number {
20
20
  return Math.min(Math.max(0, Math.floor(base + jitter)), 600000);
21
21
  }
22
22
 
23
- const HIGH = new Set(["download_click", "registration", "payment", "app_open"]);
24
- const LOW = new Set(["page_view", "scroll", "heartbeat"]);
23
+ const HIGH = new Set([
24
+ "download_click",
25
+ "registration",
26
+ "paid",
27
+ "payment",
28
+ "app_open",
29
+ ]);
30
+ const LOW = new Set(["pageview", "page_view", "scroll", "heartbeat"]);
25
31
 
26
32
  export function eventPriority(eventType: string): number {
27
33
  if (HIGH.has(eventType)) return 10;
package/src/transport.ts CHANGED
@@ -24,11 +24,18 @@ export async function postJson(
24
24
  body: string,
25
25
  timeoutMs: number,
26
26
  idempotencyKey?: string,
27
+ extraHeaders?: Record<string, string>,
27
28
  ): Promise<PostResult> {
28
29
  const ctrl = new AbortController();
29
30
  const t = setTimeout(() => ctrl.abort(), timeoutMs);
30
31
  try {
31
32
  const hdrs: Record<string, string> = { "Content-Type": "application/json" };
33
+ if (extraHeaders) {
34
+ for (const [k, v] of Object.entries(extraHeaders)) {
35
+ const key = k?.trim();
36
+ if (key) hdrs[key] = String(v);
37
+ }
38
+ }
32
39
  if (idempotencyKey) hdrs["Idempotency-Key"] = idempotencyKey;
33
40
  const res = await fetch(url, {
34
41
  method: "POST",
package/src/types.ts CHANGED
@@ -13,8 +13,25 @@ export type OutboxRecord = {
13
13
 
14
14
  export type InitConfig = {
15
15
  siteId: string;
16
- baseUrls: string[];
16
+ /**
17
+ * Primary ingest origin (no trailing slash). Merged with `baseUrls` (order: baseUrl first).
18
+ * If neither `baseUrl` nor `baseUrls` is set, the SDK tries to infer the origin from the last
19
+ * `<script src="...ytracking-web(.min).js">` on the page (same host as your deployed `/sdk/` script).
20
+ */
21
+ baseUrl?: string;
22
+ /** Ingest origins (no trailing slashes). Use with or instead of singular `baseUrl`. */
23
+ baseUrls?: string[];
17
24
  fallbackBaseUrls?: string[];
25
+ /**
26
+ * When set, all POSTs (collect, install, attribution token, domain-health report) include
27
+ * `X-YT-Sdk-Key` — same as mobile SDKs; required if your site enforces collect auth.
28
+ */
29
+ sdkKey?: string;
30
+ /**
31
+ * After IndexedDB/outbox starts, enqueue one `pageview` (default **true**).
32
+ * Set `false` if you only track SPA navigations or call `pageView()` yourself.
33
+ */
34
+ autoTrackPageView?: boolean;
18
35
  debug?: boolean;
19
36
  enableVisitorFallbackFingerprint?: boolean;
20
37
  maxQueueSize?: number;