ytracking-web 0.1.1 → 0.1.2

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
@@ -21,9 +21,9 @@ npm install ytracking-web
21
21
 
22
22
  ## 發佈至 npm
23
23
 
24
- 1. 於倉庫根目錄:`npm run check:web-sdk`、(建議)`npm run build:web-sdk`。
24
+ 1. 於倉庫根目錄先跑契約檢查:`npm run check:web-sdk`、`npm run check:openapi-drift`、(建議)`npm run build:web-sdk`。(本機 **`npm run publish:ytracking-web`** 已內含 `check:web-sdk` 與 `check:openapi-drift`。)
25
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)。
26
+ 3. **本機(免 `npm login`,適合首次發佈作法 B)**:在倉庫根目錄設定 **`NPM_TOKEN`** 後執行 **`npm run publish:ytracking-web`**(會先跑 `check:web-sdk` 再 `npm publish`)。若有 API 契約改動,請先在 root 執行 `npm run check:openapi-drift` 並同步更新 `docs/API.md` / `docs/openapi.yaml`。
27
27
 
28
28
 
29
29
  ## 使用(IIFE)
@@ -55,6 +55,7 @@ npm install ytracking-web
55
55
  - `track(type, { props })` — 對應 `POST /v1/collect` envelope
56
56
  - `trackPageView()` — `page_view`
57
57
  - `install({ clickId? })` — 內嵌 **`POST /v1/install`**(無 `appId` 之 embed 形態)
58
+ - `issueAttributionToken({ clickId?, ttlSeconds?, campaignId?, appId?, deferredContext? })` — 同步 **`POST /v1/attribution/token`**(不入佇列);沿用 `setContext` 之 visitor/session/click 等
58
59
  - `flush()` — 手動送出佇列
59
60
  - `setContext({ visitorId, sessionId, clickId, campaignId, appId })` — 後續事件帶入
60
61
  - `shutdown()` — 停止定時 flush
@@ -64,7 +65,7 @@ npm install ytracking-web
64
65
  - 每筆事件在 `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
66
  - `collect` 出站 `Idempotency-Key` 會優先使用 payload 根層 `idempotencyKey`(若缺失才回退 queue id),確保 header 與 body 同鍵。
66
67
  - collect 成功回應若包含 `visitorId`,SDK 會寫入 localStorage(`yt_vid_v1`)並自動帶入後續事件 context;可在 cookie 受限環境維持 visitor 關聯。
67
- - 須符合 `sites.allowed_origins`(見 [SITE_AND_EMBED.md](../../docs/SITE_AND_EMBED.md))。
68
+ - 須符合 `sites.allowed_origins`(見 [SITE_AND_EMBED.md](../../docs/SITE_AND_EMBED.md))。SDK collect 出站採 `credentials: "omit"`,不依賴瀏覽器 cookie。
68
69
  - fallback 會維護 endpoint pool + cursor;成功入口可刷新新 hosts,不可達時會送 best-effort `domain-health/report`(含節流)。
69
70
  - `Retry-After` 同時支援秒數與 HTTP-date;`shutdown()` 會解除 `visibilitychange` listener,避免重複初始化時累積監聽器。
70
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ytracking-web",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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
@@ -45,6 +45,10 @@ function installUrl(origin: string): string {
45
45
  return `${origin}/v1/install`;
46
46
  }
47
47
 
48
+ function attributionTokenUrl(origin: string): string {
49
+ return `${origin}/v1/attribution/token`;
50
+ }
51
+
48
52
  export class YTrackingWebClient {
49
53
  private cfg: Required<
50
54
  Pick<InitConfig, "siteId" | "baseUrls"> & {
@@ -200,6 +204,105 @@ export class YTrackingWebClient {
200
204
  });
201
205
  }
202
206
 
207
+ /**
208
+ * Issue a one-time deferred deep link token (`POST /v1/attribution/token`).
209
+ * Synchronous HTTP (not queued); rotates ingestion origins on success like collect/install.
210
+ */
211
+ async issueAttributionToken(opts?: {
212
+ clickId?: string;
213
+ ttlSeconds?: number;
214
+ campaignId?: string;
215
+ appId?: string;
216
+ deferredContext?: Record<string, unknown>;
217
+ }): Promise<{ attributionToken: string; expiresAt: string }> {
218
+ const body: Record<string, unknown> = { siteId: this.cfg.siteId };
219
+ const cid = opts?.clickId?.trim() || this.context.clickId?.trim();
220
+ if (cid) body.clickId = cid;
221
+ const vid = this.context.visitorId?.trim();
222
+ if (vid) body.visitorId = vid;
223
+ const sid = this.context.sessionId?.trim();
224
+ if (sid) body.sessionId = sid;
225
+ const camp = opts?.campaignId?.trim() || this.context.campaignId?.trim();
226
+ if (camp) body.campaignId = camp;
227
+ const aid = opts?.appId?.trim() || this.context.appId?.trim();
228
+ if (aid) body.appId = aid;
229
+ if (opts?.ttlSeconds != null) body.ttlSeconds = opts.ttlSeconds;
230
+ if (
231
+ opts?.deferredContext &&
232
+ typeof opts.deferredContext === "object" &&
233
+ !Array.isArray(opts.deferredContext)
234
+ ) {
235
+ body.deferredContext = opts.deferredContext;
236
+ }
237
+ const payload = JSON.stringify(body);
238
+ let lastRetryAfter: number | undefined;
239
+
240
+ for (const origin of this.originsInOrder()) {
241
+ const url = attributionTokenUrl(origin);
242
+ const res = await postJson(
243
+ url,
244
+ payload,
245
+ this.cfg.requestTimeoutMs,
246
+ undefined,
247
+ );
248
+ if (!res.ok) {
249
+ log(
250
+ this.cfg.debug,
251
+ "issueAttributionToken transport fail",
252
+ origin,
253
+ res.kind,
254
+ );
255
+ if (res.retryAfterMs !== undefined) lastRetryAfter = res.retryAfterMs;
256
+ if (res.kind === "network" || res.kind === "timeout") {
257
+ await this.reportDomainUnreachable(origin, res.kind);
258
+ this.rotateCursorAfter(origin);
259
+ }
260
+ continue;
261
+ }
262
+ if (res.retryAfterMs !== undefined) lastRetryAfter = res.retryAfterMs;
263
+
264
+ if (res.status === 201) {
265
+ const j = res.bodyJson as {
266
+ attributionToken?: unknown;
267
+ expiresAt?: unknown;
268
+ };
269
+ const tok =
270
+ typeof j?.attributionToken === "string"
271
+ ? j.attributionToken.trim()
272
+ : "";
273
+ const exp = typeof j?.expiresAt === "string" ? j.expiresAt.trim() : "";
274
+ if (tok && exp) {
275
+ this.setCursorToOrigin(origin);
276
+ await this.refreshEndpointPool(origin);
277
+ return { attributionToken: tok, expiresAt: exp };
278
+ }
279
+ }
280
+ if (isNonRetryableStatus(res.status)) {
281
+ throw new Error(
282
+ `YTrackingWeb: issueAttributionToken failed with HTTP ${res.status}`,
283
+ );
284
+ }
285
+ if (isRetryableStatus(res.status)) {
286
+ log(
287
+ this.cfg.debug,
288
+ "issueAttributionToken retryable",
289
+ res.status,
290
+ origin,
291
+ );
292
+ continue;
293
+ }
294
+ throw new Error(
295
+ `YTrackingWeb: issueAttributionToken failed with HTTP ${res.status}`,
296
+ );
297
+ }
298
+
299
+ throw new Error(
300
+ lastRetryAfter !== undefined
301
+ ? `YTrackingWeb: issueAttributionToken failed (Retry-After hint ${lastRetryAfter}ms)`
302
+ : "YTrackingWeb: issueAttributionToken failed on all origins",
303
+ );
304
+ }
305
+
203
306
  private async enqueue(p: {
204
307
  kind: OutboxRecord["kind"];
205
308
  payload: string;
@@ -567,6 +670,17 @@ export async function install(opts?: { clickId?: string }): Promise<void> {
567
670
  return singleton.install(opts);
568
671
  }
569
672
 
673
+ export async function issueAttributionToken(opts?: {
674
+ clickId?: string;
675
+ ttlSeconds?: number;
676
+ campaignId?: string;
677
+ appId?: string;
678
+ deferredContext?: Record<string, unknown>;
679
+ }): Promise<{ attributionToken: string; expiresAt: string }> {
680
+ if (!singleton) throw new Error("YTrackingWeb: call init() first");
681
+ return singleton.issueAttributionToken(opts);
682
+ }
683
+
570
684
  export function setContext(partial: {
571
685
  visitorId?: string;
572
686
  sessionId?: string;
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export {
13
13
  flush,
14
14
  shutdown,
15
15
  install,
16
+ issueAttributionToken,
16
17
  setContext,
17
18
  getClient,
18
19
  YTrackingWebClient,
package/src/transport.ts CHANGED
@@ -34,7 +34,9 @@ export async function postJson(
34
34
  method: "POST",
35
35
  headers: hdrs,
36
36
  body,
37
- credentials: "include",
37
+ // Collect does not depend on cookies. Keep this non-credentialed so
38
+ // wildcard ACAO ("*") deployments remain browser-compatible.
39
+ credentials: "omit",
38
40
  signal: ctrl.signal,
39
41
  keepalive: true,
40
42
  });