rsclick-log-sdk-web 0.1.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 ADDED
@@ -0,0 +1,181 @@
1
+ # `rsclick-log-sdk-web`
2
+
3
+ 用于 `rs-click-log` MVP 的轻量 Web 埋点 SDK。
4
+
5
+ 目标:
6
+
7
+ - 在浏览器端生成并持久化 `anonymous_id`
8
+ - 自动维护 30 分钟 `session_id`
9
+ - 调用后端 `POST /track/collect` 上报事件
10
+ - 默认初始化后自动发送 `$page_view`
11
+ - 登录态识别仅影响后续事件
12
+
13
+ ## 目录
14
+
15
+ ```text
16
+ fronts/sdk-web
17
+ ├── src
18
+ ├── examples
19
+ ├── tests
20
+ └── package.json
21
+ ```
22
+
23
+ ## 开发命令
24
+
25
+ ```bash
26
+ cd fronts/sdk-web
27
+ bun test
28
+ npx -p typescript tsc --noEmit -p tsconfig.json
29
+ bun run build
30
+ ```
31
+
32
+ Vue 示例:
33
+
34
+ ```bash
35
+ cd fronts/sdk-web/examples/vue
36
+ npm install
37
+ npm run dev
38
+ ```
39
+
40
+ ## 输出产物
41
+
42
+ - `dist/index.js`: ESM 构建产物
43
+ - `dist/index.global.js`: 浏览器全局 bundle,暴露 `window.TrackingAnalyticsSDK`
44
+ - `dist/index.d.ts`: ESM 入口类型声明
45
+
46
+ ## 初始化示例
47
+
48
+ ```ts
49
+ import { init } from "rsclick-log-sdk-web";
50
+
51
+ await init({
52
+ writeKey: "trk_live_xxx",
53
+ endpoint: "https://your-api.example.com/track/collect",
54
+ });
55
+ ```
56
+
57
+ 关闭默认 `$page_view`:
58
+
59
+ ```ts
60
+ import { init } from "rsclick-log-sdk-web";
61
+
62
+ await init({
63
+ writeKey: "trk_live_xxx",
64
+ endpoint: "https://your-api.example.com/track/collect",
65
+ autoPageview: false,
66
+ });
67
+ ```
68
+
69
+ ## 事件示例
70
+
71
+ ```ts
72
+ import { track } from "rsclick-log-sdk-web";
73
+
74
+ await track("click_signup_button", {
75
+ properties: {
76
+ button_name: "立即注册",
77
+ page_section: "hero",
78
+ },
79
+ });
80
+ ```
81
+
82
+ 主动发送页面浏览事件:
83
+
84
+ ```ts
85
+ import { page } from "rsclick-log-sdk-web";
86
+
87
+ await page({
88
+ properties: {
89
+ from: "manual-refresh",
90
+ },
91
+ });
92
+ ```
93
+
94
+ ## Identify 示例
95
+
96
+ ```ts
97
+ import { identify, track } from "rsclick-log-sdk-web";
98
+
99
+ identify("user_123");
100
+
101
+ await track("submit_signup_form", {
102
+ properties: {
103
+ plan_type: "pro",
104
+ },
105
+ });
106
+ ```
107
+
108
+ `identify` 只影响后续事件,不会回溯历史匿名事件。
109
+
110
+ ## 浏览器直接接入
111
+
112
+ ```html
113
+ <script src="/sdk/index.global.js"></script>
114
+ <script>
115
+ TrackingAnalyticsSDK.init({
116
+ writeKey: "trk_live_xxx",
117
+ endpoint: "https://your-api.example.com/track/collect"
118
+ }).then(function () {
119
+ return TrackingAnalyticsSDK.track("landing_cta_click", {
120
+ properties: {
121
+ section: "hero"
122
+ }
123
+ });
124
+ });
125
+ </script>
126
+ ```
127
+
128
+ ## 采集字段
129
+
130
+ SDK 默认上报以下字段:
131
+
132
+ - `anonymous_id`
133
+ - `session_id`
134
+ - `user_id`
135
+ - `page_url`
136
+ - `page_path`
137
+ - `page_title`
138
+ - `referrer`
139
+ - `utm_source`
140
+ - `utm_medium`
141
+ - `utm_campaign`
142
+ - `properties`
143
+
144
+ 传输策略:
145
+
146
+ 1. 优先调用 `navigator.sendBeacon`
147
+ 2. 如果 `sendBeacon` 返回 `false` 或抛错,则回退到 `fetch(..., { keepalive: true })`
148
+
149
+ ## 发布说明
150
+
151
+ 当前 npm 包名为 `rsclick-log-sdk-web`。如果要发布到 npm,按下面步骤操作:
152
+
153
+ 1. 进入 SDK 目录并执行发布前检查。
154
+
155
+ ```bash
156
+ cd fronts/sdk-web
157
+ bun test
158
+ bun run build
159
+ npx -p typescript tsc --noEmit -p tsconfig.json
160
+ npm pack --dry-run
161
+ ```
162
+
163
+ 2. 登录 npm 并发布:
164
+
165
+ ```bash
166
+ npm login
167
+ npm publish --access public
168
+ ```
169
+
170
+ 如果本机 `~/.npmrc` 残留旧 token,优先清理后再登录,否则 `npm publish` 可能不会使用当前登录态。
171
+
172
+ 当前包通过 `files` 字段控制发布内容,默认只会带上:
173
+
174
+ - `dist`
175
+ - `README.md`
176
+
177
+ 如果只想内部使用,不需要发布到 npm,可以直接分发构建产物:
178
+
179
+ - `dist/index.js`
180
+ - `dist/index.global.js`
181
+ - `dist/index.d.ts`
@@ -0,0 +1,9 @@
1
+ declare const TrackingAnalyticsSDK: {
2
+ init: (options: import("./types").TrackingInitOptions) => Promise<void>;
3
+ track: (eventName: string, options?: import("./types").TrackOptions) => Promise<import("./types").TrackingDispatchResult>;
4
+ identify: (userId: string | null) => void;
5
+ page: (options?: import("./types").PageOptions) => Promise<import("./types").TrackingDispatchResult>;
6
+ reset: () => import("./types").TrackingStateSnapshot;
7
+ getDebugState: () => import("./types").TrackingStateSnapshot;
8
+ };
9
+ export { TrackingAnalyticsSDK };
@@ -0,0 +1,2 @@
1
+ export { getDebugState, identify, init, page, reset, track } from "./sdk";
2
+ export type { PageOptions, TrackOptions, TrackingCollectPayload, TrackingDispatchResult, TrackingEventPayload, TrackingInitOptions, TrackingProperties, TrackingResponse, TrackingScalar, TrackingStateSnapshot, } from "./types";
@@ -0,0 +1,359 @@
1
+ (() => {
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __moduleCache = /* @__PURE__ */ new WeakMap;
7
+ var __toCommonJS = (from) => {
8
+ var entry = __moduleCache.get(from), desc;
9
+ if (entry)
10
+ return entry;
11
+ entry = __defProp({}, "__esModule", { value: true });
12
+ if (from && typeof from === "object" || typeof from === "function")
13
+ __getOwnPropNames(from).map((key) => !__hasOwnProp.call(entry, key) && __defProp(entry, key, {
14
+ get: () => from[key],
15
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
16
+ }));
17
+ __moduleCache.set(from, entry);
18
+ return entry;
19
+ };
20
+ var __export = (target, all) => {
21
+ for (var name in all)
22
+ __defProp(target, name, {
23
+ get: all[name],
24
+ enumerable: true,
25
+ configurable: true,
26
+ set: (newValue) => all[name] = () => newValue
27
+ });
28
+ };
29
+
30
+ // src/browser.ts
31
+ var exports_browser = {};
32
+ __export(exports_browser, {
33
+ TrackingAnalyticsSDK: () => TrackingAnalyticsSDK
34
+ });
35
+
36
+ // src/sdk.ts
37
+ var DEFAULT_ENDPOINT = "/track/collect";
38
+ var DEFAULT_SESSION_TIMEOUT_MINUTES = 30;
39
+ var DEFAULT_STORAGE_PREFIX = "rs_tracking_sdk";
40
+ var STORAGE_KEYS = {
41
+ anonymousId: "anonymous_id",
42
+ sessionId: "session_id",
43
+ lastActivityAt: "last_activity_at",
44
+ userId: "user_id"
45
+ };
46
+ var trackingClient = null;
47
+ var init = async (options) => {
48
+ trackingClient = createTrackingClient(options);
49
+ await trackingClient.init();
50
+ };
51
+ var track = async (eventName, options = {}) => getClient().track(eventName, options);
52
+ var identify = (userId) => {
53
+ getClient().identify(userId);
54
+ };
55
+ var page = async (options = {}) => getClient().page(options);
56
+ var reset = () => getClient().reset();
57
+ var getDebugState = () => getClient().getState();
58
+ var createTrackingClient = (options) => {
59
+ const browser = resolveBrowser();
60
+ const config = normalizeConfig(options);
61
+ const storage = createScopedStorage(browser, config.storagePrefix);
62
+ let state = loadState(browser, storage);
63
+ const touchSession = (now) => {
64
+ const timeoutExceeded = now - state.lastActivityAt >= config.sessionTimeoutMs / 1000;
65
+ if (timeoutExceeded) {
66
+ state = {
67
+ ...state,
68
+ sessionId: generateId(browser)
69
+ };
70
+ }
71
+ state = {
72
+ ...state,
73
+ lastActivityAt: now
74
+ };
75
+ persistState(storage, state);
76
+ return state;
77
+ };
78
+ const buildEventPayload = (eventName, options2 = {}) => {
79
+ const now = currentUnixTime(browser);
80
+ const nextState = touchSession(now);
81
+ const pageContext = resolvePageContext(browser, options2.page);
82
+ const utm = extractUtm(pageContext.url);
83
+ return {
84
+ event_name: eventName.trim(),
85
+ event_time: options2.eventTime,
86
+ anonymous_id: nextState.anonymousId,
87
+ session_id: nextState.sessionId,
88
+ user_id: nextState.userId ?? undefined,
89
+ page_url: pageContext.url,
90
+ page_path: pageContext.path,
91
+ page_title: pageContext.title,
92
+ referrer: pageContext.referrer,
93
+ utm_source: utm.utm_source,
94
+ utm_medium: utm.utm_medium,
95
+ utm_campaign: utm.utm_campaign,
96
+ properties: sanitizeProperties(options2.properties)
97
+ };
98
+ };
99
+ const dispatch = async (payload) => {
100
+ const body = JSON.stringify(payload);
101
+ if (canUseBeacon(browser)) {
102
+ try {
103
+ const blob = createJsonBlob(browser, body);
104
+ const sent = browser.navigator?.sendBeacon?.(config.endpoint, blob);
105
+ if (sent) {
106
+ return { transport: "beacon" };
107
+ }
108
+ } catch {
109
+ }
110
+ }
111
+ const response = await postWithFetch(browser, config.endpoint, body);
112
+ return {
113
+ transport: "fetch",
114
+ response
115
+ };
116
+ };
117
+ return {
118
+ init: async () => {
119
+ if (config.autoPageview) {
120
+ await dispatch({
121
+ write_key: config.writeKey,
122
+ events: [buildEventPayload("$page_view")]
123
+ });
124
+ }
125
+ },
126
+ track: async (eventName, options2 = {}) => {
127
+ const payload = {
128
+ write_key: config.writeKey,
129
+ events: [buildEventPayload(eventName, options2)]
130
+ };
131
+ return dispatch(payload);
132
+ },
133
+ identify: (userId) => {
134
+ state = {
135
+ ...state,
136
+ userId: normalizeOptionalString(userId)
137
+ };
138
+ persistState(storage, state);
139
+ },
140
+ page: async (options2 = {}) => dispatch({
141
+ write_key: config.writeKey,
142
+ events: [
143
+ buildEventPayload("$page_view", {
144
+ properties: options2.properties,
145
+ page: options2
146
+ })
147
+ ]
148
+ }),
149
+ reset: () => {
150
+ clearState(storage);
151
+ state = createFreshState(browser);
152
+ persistState(storage, state);
153
+ return { ...state };
154
+ },
155
+ getState: () => ({ ...state })
156
+ };
157
+ };
158
+ var getClient = () => {
159
+ if (!trackingClient) {
160
+ throw new Error("Tracking SDK has not been initialized");
161
+ }
162
+ return trackingClient;
163
+ };
164
+ var resolveBrowser = () => {
165
+ const candidate = globalThis;
166
+ return {
167
+ document: candidate.document,
168
+ location: candidate.location,
169
+ navigator: candidate.navigator,
170
+ screen: candidate.screen,
171
+ localStorage: candidate.localStorage,
172
+ fetch: candidate.fetch?.bind(globalThis),
173
+ crypto: candidate.crypto,
174
+ Blob: candidate.Blob,
175
+ Date
176
+ };
177
+ };
178
+ var normalizeConfig = (options) => {
179
+ const writeKey = options.writeKey.trim();
180
+ if (!writeKey) {
181
+ throw new Error("Tracking writeKey is required");
182
+ }
183
+ return {
184
+ writeKey,
185
+ endpoint: normalizeOptionalString(options.endpoint) ?? DEFAULT_ENDPOINT,
186
+ autoPageview: options.autoPageview ?? true,
187
+ sessionTimeoutMs: Math.max(options.sessionTimeoutMinutes ?? DEFAULT_SESSION_TIMEOUT_MINUTES, 1) * 60000,
188
+ storagePrefix: normalizeOptionalString(options.storagePrefix) ?? DEFAULT_STORAGE_PREFIX
189
+ };
190
+ };
191
+ var createScopedStorage = (browser, prefix) => {
192
+ const nativeStorage = browser.localStorage;
193
+ if (!nativeStorage) {
194
+ const memoryStore = new Map;
195
+ return {
196
+ getItem: (key) => memoryStore.get(key) ?? null,
197
+ setItem: (key, value) => {
198
+ memoryStore.set(key, value);
199
+ },
200
+ removeItem: (key) => {
201
+ memoryStore.delete(key);
202
+ }
203
+ };
204
+ }
205
+ return {
206
+ getItem: (key) => nativeStorage.getItem(`${prefix}:${key}`),
207
+ setItem: (key, value) => {
208
+ nativeStorage.setItem(`${prefix}:${key}`, value);
209
+ },
210
+ removeItem: (key) => {
211
+ nativeStorage.removeItem(`${prefix}:${key}`);
212
+ }
213
+ };
214
+ };
215
+ var loadState = (browser, storage) => {
216
+ const anonymousId = normalizeOptionalString(storage.getItem(STORAGE_KEYS.anonymousId));
217
+ const sessionId = normalizeOptionalString(storage.getItem(STORAGE_KEYS.sessionId));
218
+ const userId = normalizeOptionalString(storage.getItem(STORAGE_KEYS.userId));
219
+ const lastActivityAt = parseInteger(storage.getItem(STORAGE_KEYS.lastActivityAt));
220
+ if (anonymousId && sessionId && lastActivityAt) {
221
+ return {
222
+ anonymousId,
223
+ sessionId,
224
+ userId,
225
+ lastActivityAt
226
+ };
227
+ }
228
+ const freshState = createFreshState(browser);
229
+ persistState(storage, freshState);
230
+ return freshState;
231
+ };
232
+ var createFreshState = (browser) => {
233
+ const now = currentUnixTime(browser);
234
+ return {
235
+ anonymousId: generateId(browser),
236
+ sessionId: generateId(browser),
237
+ userId: null,
238
+ lastActivityAt: now
239
+ };
240
+ };
241
+ var persistState = (storage, state) => {
242
+ storage.setItem(STORAGE_KEYS.anonymousId, state.anonymousId);
243
+ storage.setItem(STORAGE_KEYS.sessionId, state.sessionId);
244
+ storage.setItem(STORAGE_KEYS.lastActivityAt, String(state.lastActivityAt));
245
+ if (state.userId) {
246
+ storage.setItem(STORAGE_KEYS.userId, state.userId);
247
+ return;
248
+ }
249
+ storage.removeItem(STORAGE_KEYS.userId);
250
+ };
251
+ var clearState = (storage) => {
252
+ storage.removeItem(STORAGE_KEYS.anonymousId);
253
+ storage.removeItem(STORAGE_KEYS.sessionId);
254
+ storage.removeItem(STORAGE_KEYS.lastActivityAt);
255
+ storage.removeItem(STORAGE_KEYS.userId);
256
+ };
257
+ var currentUnixTime = (browser) => Math.floor((browser.Date ?? Date).now() / 1000);
258
+ var generateId = (browser) => {
259
+ if (browser.crypto?.randomUUID) {
260
+ return browser.crypto.randomUUID();
261
+ }
262
+ const bytes = new Uint8Array(16);
263
+ if (browser.crypto?.getRandomValues) {
264
+ browser.crypto.getRandomValues(bytes);
265
+ } else {
266
+ for (let index = 0;index < bytes.length; index += 1) {
267
+ bytes[index] = Math.floor(Math.random() * 256);
268
+ }
269
+ }
270
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
271
+ };
272
+ var resolvePageContext = (browser, page2 = {}) => ({
273
+ url: normalizeOptionalString(page2.url) ?? undefinedIfNull(normalizeOptionalString(browser.location?.href)),
274
+ path: normalizeOptionalString(page2.path) ?? undefinedIfNull(normalizeOptionalString(browser.location?.pathname)),
275
+ title: normalizeOptionalString(page2.title) ?? undefinedIfNull(normalizeOptionalString(browser.document?.title)),
276
+ referrer: normalizeOptionalString(page2.referrer) ?? undefinedIfNull(normalizeOptionalString(browser.document?.referrer))
277
+ });
278
+ var extractUtm = (url) => {
279
+ if (!url) {
280
+ return {};
281
+ }
282
+ try {
283
+ const parsed = new URL(url, "https://sdk.local");
284
+ return {
285
+ utm_source: undefinedIfNull(normalizeOptionalString(parsed.searchParams.get("utm_source"))),
286
+ utm_medium: undefinedIfNull(normalizeOptionalString(parsed.searchParams.get("utm_medium"))),
287
+ utm_campaign: undefinedIfNull(normalizeOptionalString(parsed.searchParams.get("utm_campaign")))
288
+ };
289
+ } catch {
290
+ return {};
291
+ }
292
+ };
293
+ var sanitizeProperties = (properties) => {
294
+ if (!properties) {
295
+ return {};
296
+ }
297
+ return Object.entries(properties).reduce((accumulator, [key, value]) => {
298
+ const normalizedKey = key.trim();
299
+ if (!normalizedKey) {
300
+ return accumulator;
301
+ }
302
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) {
303
+ accumulator[normalizedKey] = value;
304
+ }
305
+ return accumulator;
306
+ }, {});
307
+ };
308
+ var normalizeOptionalString = (value) => {
309
+ const trimmed = value?.trim();
310
+ return trimmed ? trimmed : null;
311
+ };
312
+ var undefinedIfNull = (value) => value ?? undefined;
313
+ var parseInteger = (value) => {
314
+ if (!value) {
315
+ return null;
316
+ }
317
+ const parsed = Number.parseInt(value, 10);
318
+ return Number.isFinite(parsed) ? parsed : null;
319
+ };
320
+ var canUseBeacon = (browser) => typeof browser.navigator?.sendBeacon === "function";
321
+ var createJsonBlob = (browser, body) => {
322
+ if (browser.Blob) {
323
+ return new browser.Blob([body], { type: "application/json" });
324
+ }
325
+ return body;
326
+ };
327
+ var postWithFetch = async (browser, endpoint, body) => {
328
+ if (!browser.fetch) {
329
+ throw new Error("Tracking SDK requires fetch when sendBeacon is unavailable");
330
+ }
331
+ const response = await browser.fetch(endpoint, {
332
+ method: "POST",
333
+ keepalive: true,
334
+ headers: {
335
+ "content-type": "application/json"
336
+ },
337
+ body
338
+ });
339
+ if (!response.ok) {
340
+ throw new Error(`Tracking collect request failed with status ${response.status}`);
341
+ }
342
+ const contentType = response.headers.get("content-type") ?? "";
343
+ if (!contentType.includes("application/json")) {
344
+ return;
345
+ }
346
+ return await response.json();
347
+ };
348
+ // src/browser.ts
349
+ var TrackingAnalyticsSDK = {
350
+ init,
351
+ track,
352
+ identify,
353
+ page,
354
+ reset,
355
+ getDebugState
356
+ };
357
+ var browserGlobal = globalThis;
358
+ browserGlobal.TrackingAnalyticsSDK = TrackingAnalyticsSDK;
359
+ })();
package/dist/index.js ADDED
@@ -0,0 +1,320 @@
1
+ // src/sdk.ts
2
+ var DEFAULT_ENDPOINT = "/track/collect";
3
+ var DEFAULT_SESSION_TIMEOUT_MINUTES = 30;
4
+ var DEFAULT_STORAGE_PREFIX = "rs_tracking_sdk";
5
+ var STORAGE_KEYS = {
6
+ anonymousId: "anonymous_id",
7
+ sessionId: "session_id",
8
+ lastActivityAt: "last_activity_at",
9
+ userId: "user_id"
10
+ };
11
+ var trackingClient = null;
12
+ var init = async (options) => {
13
+ trackingClient = createTrackingClient(options);
14
+ await trackingClient.init();
15
+ };
16
+ var track = async (eventName, options = {}) => getClient().track(eventName, options);
17
+ var identify = (userId) => {
18
+ getClient().identify(userId);
19
+ };
20
+ var page = async (options = {}) => getClient().page(options);
21
+ var reset = () => getClient().reset();
22
+ var getDebugState = () => getClient().getState();
23
+ var createTrackingClient = (options) => {
24
+ const browser = resolveBrowser();
25
+ const config = normalizeConfig(options);
26
+ const storage = createScopedStorage(browser, config.storagePrefix);
27
+ let state = loadState(browser, storage);
28
+ const touchSession = (now) => {
29
+ const timeoutExceeded = now - state.lastActivityAt >= config.sessionTimeoutMs / 1000;
30
+ if (timeoutExceeded) {
31
+ state = {
32
+ ...state,
33
+ sessionId: generateId(browser)
34
+ };
35
+ }
36
+ state = {
37
+ ...state,
38
+ lastActivityAt: now
39
+ };
40
+ persistState(storage, state);
41
+ return state;
42
+ };
43
+ const buildEventPayload = (eventName, options2 = {}) => {
44
+ const now = currentUnixTime(browser);
45
+ const nextState = touchSession(now);
46
+ const pageContext = resolvePageContext(browser, options2.page);
47
+ const utm = extractUtm(pageContext.url);
48
+ return {
49
+ event_name: eventName.trim(),
50
+ event_time: options2.eventTime,
51
+ anonymous_id: nextState.anonymousId,
52
+ session_id: nextState.sessionId,
53
+ user_id: nextState.userId ?? undefined,
54
+ page_url: pageContext.url,
55
+ page_path: pageContext.path,
56
+ page_title: pageContext.title,
57
+ referrer: pageContext.referrer,
58
+ utm_source: utm.utm_source,
59
+ utm_medium: utm.utm_medium,
60
+ utm_campaign: utm.utm_campaign,
61
+ properties: sanitizeProperties(options2.properties)
62
+ };
63
+ };
64
+ const dispatch = async (payload) => {
65
+ const body = JSON.stringify(payload);
66
+ if (canUseBeacon(browser)) {
67
+ try {
68
+ const blob = createJsonBlob(browser, body);
69
+ const sent = browser.navigator?.sendBeacon?.(config.endpoint, blob);
70
+ if (sent) {
71
+ return { transport: "beacon" };
72
+ }
73
+ } catch {
74
+ }
75
+ }
76
+ const response = await postWithFetch(browser, config.endpoint, body);
77
+ return {
78
+ transport: "fetch",
79
+ response
80
+ };
81
+ };
82
+ return {
83
+ init: async () => {
84
+ if (config.autoPageview) {
85
+ await dispatch({
86
+ write_key: config.writeKey,
87
+ events: [buildEventPayload("$page_view")]
88
+ });
89
+ }
90
+ },
91
+ track: async (eventName, options2 = {}) => {
92
+ const payload = {
93
+ write_key: config.writeKey,
94
+ events: [buildEventPayload(eventName, options2)]
95
+ };
96
+ return dispatch(payload);
97
+ },
98
+ identify: (userId) => {
99
+ state = {
100
+ ...state,
101
+ userId: normalizeOptionalString(userId)
102
+ };
103
+ persistState(storage, state);
104
+ },
105
+ page: async (options2 = {}) => dispatch({
106
+ write_key: config.writeKey,
107
+ events: [
108
+ buildEventPayload("$page_view", {
109
+ properties: options2.properties,
110
+ page: options2
111
+ })
112
+ ]
113
+ }),
114
+ reset: () => {
115
+ clearState(storage);
116
+ state = createFreshState(browser);
117
+ persistState(storage, state);
118
+ return { ...state };
119
+ },
120
+ getState: () => ({ ...state })
121
+ };
122
+ };
123
+ var getClient = () => {
124
+ if (!trackingClient) {
125
+ throw new Error("Tracking SDK has not been initialized");
126
+ }
127
+ return trackingClient;
128
+ };
129
+ var resolveBrowser = () => {
130
+ const candidate = globalThis;
131
+ return {
132
+ document: candidate.document,
133
+ location: candidate.location,
134
+ navigator: candidate.navigator,
135
+ screen: candidate.screen,
136
+ localStorage: candidate.localStorage,
137
+ fetch: candidate.fetch?.bind(globalThis),
138
+ crypto: candidate.crypto,
139
+ Blob: candidate.Blob,
140
+ Date
141
+ };
142
+ };
143
+ var normalizeConfig = (options) => {
144
+ const writeKey = options.writeKey.trim();
145
+ if (!writeKey) {
146
+ throw new Error("Tracking writeKey is required");
147
+ }
148
+ return {
149
+ writeKey,
150
+ endpoint: normalizeOptionalString(options.endpoint) ?? DEFAULT_ENDPOINT,
151
+ autoPageview: options.autoPageview ?? true,
152
+ sessionTimeoutMs: Math.max(options.sessionTimeoutMinutes ?? DEFAULT_SESSION_TIMEOUT_MINUTES, 1) * 60000,
153
+ storagePrefix: normalizeOptionalString(options.storagePrefix) ?? DEFAULT_STORAGE_PREFIX
154
+ };
155
+ };
156
+ var createScopedStorage = (browser, prefix) => {
157
+ const nativeStorage = browser.localStorage;
158
+ if (!nativeStorage) {
159
+ const memoryStore = new Map;
160
+ return {
161
+ getItem: (key) => memoryStore.get(key) ?? null,
162
+ setItem: (key, value) => {
163
+ memoryStore.set(key, value);
164
+ },
165
+ removeItem: (key) => {
166
+ memoryStore.delete(key);
167
+ }
168
+ };
169
+ }
170
+ return {
171
+ getItem: (key) => nativeStorage.getItem(`${prefix}:${key}`),
172
+ setItem: (key, value) => {
173
+ nativeStorage.setItem(`${prefix}:${key}`, value);
174
+ },
175
+ removeItem: (key) => {
176
+ nativeStorage.removeItem(`${prefix}:${key}`);
177
+ }
178
+ };
179
+ };
180
+ var loadState = (browser, storage) => {
181
+ const anonymousId = normalizeOptionalString(storage.getItem(STORAGE_KEYS.anonymousId));
182
+ const sessionId = normalizeOptionalString(storage.getItem(STORAGE_KEYS.sessionId));
183
+ const userId = normalizeOptionalString(storage.getItem(STORAGE_KEYS.userId));
184
+ const lastActivityAt = parseInteger(storage.getItem(STORAGE_KEYS.lastActivityAt));
185
+ if (anonymousId && sessionId && lastActivityAt) {
186
+ return {
187
+ anonymousId,
188
+ sessionId,
189
+ userId,
190
+ lastActivityAt
191
+ };
192
+ }
193
+ const freshState = createFreshState(browser);
194
+ persistState(storage, freshState);
195
+ return freshState;
196
+ };
197
+ var createFreshState = (browser) => {
198
+ const now = currentUnixTime(browser);
199
+ return {
200
+ anonymousId: generateId(browser),
201
+ sessionId: generateId(browser),
202
+ userId: null,
203
+ lastActivityAt: now
204
+ };
205
+ };
206
+ var persistState = (storage, state) => {
207
+ storage.setItem(STORAGE_KEYS.anonymousId, state.anonymousId);
208
+ storage.setItem(STORAGE_KEYS.sessionId, state.sessionId);
209
+ storage.setItem(STORAGE_KEYS.lastActivityAt, String(state.lastActivityAt));
210
+ if (state.userId) {
211
+ storage.setItem(STORAGE_KEYS.userId, state.userId);
212
+ return;
213
+ }
214
+ storage.removeItem(STORAGE_KEYS.userId);
215
+ };
216
+ var clearState = (storage) => {
217
+ storage.removeItem(STORAGE_KEYS.anonymousId);
218
+ storage.removeItem(STORAGE_KEYS.sessionId);
219
+ storage.removeItem(STORAGE_KEYS.lastActivityAt);
220
+ storage.removeItem(STORAGE_KEYS.userId);
221
+ };
222
+ var currentUnixTime = (browser) => Math.floor((browser.Date ?? Date).now() / 1000);
223
+ var generateId = (browser) => {
224
+ if (browser.crypto?.randomUUID) {
225
+ return browser.crypto.randomUUID();
226
+ }
227
+ const bytes = new Uint8Array(16);
228
+ if (browser.crypto?.getRandomValues) {
229
+ browser.crypto.getRandomValues(bytes);
230
+ } else {
231
+ for (let index = 0;index < bytes.length; index += 1) {
232
+ bytes[index] = Math.floor(Math.random() * 256);
233
+ }
234
+ }
235
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
236
+ };
237
+ var resolvePageContext = (browser, page2 = {}) => ({
238
+ url: normalizeOptionalString(page2.url) ?? undefinedIfNull(normalizeOptionalString(browser.location?.href)),
239
+ path: normalizeOptionalString(page2.path) ?? undefinedIfNull(normalizeOptionalString(browser.location?.pathname)),
240
+ title: normalizeOptionalString(page2.title) ?? undefinedIfNull(normalizeOptionalString(browser.document?.title)),
241
+ referrer: normalizeOptionalString(page2.referrer) ?? undefinedIfNull(normalizeOptionalString(browser.document?.referrer))
242
+ });
243
+ var extractUtm = (url) => {
244
+ if (!url) {
245
+ return {};
246
+ }
247
+ try {
248
+ const parsed = new URL(url, "https://sdk.local");
249
+ return {
250
+ utm_source: undefinedIfNull(normalizeOptionalString(parsed.searchParams.get("utm_source"))),
251
+ utm_medium: undefinedIfNull(normalizeOptionalString(parsed.searchParams.get("utm_medium"))),
252
+ utm_campaign: undefinedIfNull(normalizeOptionalString(parsed.searchParams.get("utm_campaign")))
253
+ };
254
+ } catch {
255
+ return {};
256
+ }
257
+ };
258
+ var sanitizeProperties = (properties) => {
259
+ if (!properties) {
260
+ return {};
261
+ }
262
+ return Object.entries(properties).reduce((accumulator, [key, value]) => {
263
+ const normalizedKey = key.trim();
264
+ if (!normalizedKey) {
265
+ return accumulator;
266
+ }
267
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) {
268
+ accumulator[normalizedKey] = value;
269
+ }
270
+ return accumulator;
271
+ }, {});
272
+ };
273
+ var normalizeOptionalString = (value) => {
274
+ const trimmed = value?.trim();
275
+ return trimmed ? trimmed : null;
276
+ };
277
+ var undefinedIfNull = (value) => value ?? undefined;
278
+ var parseInteger = (value) => {
279
+ if (!value) {
280
+ return null;
281
+ }
282
+ const parsed = Number.parseInt(value, 10);
283
+ return Number.isFinite(parsed) ? parsed : null;
284
+ };
285
+ var canUseBeacon = (browser) => typeof browser.navigator?.sendBeacon === "function";
286
+ var createJsonBlob = (browser, body) => {
287
+ if (browser.Blob) {
288
+ return new browser.Blob([body], { type: "application/json" });
289
+ }
290
+ return body;
291
+ };
292
+ var postWithFetch = async (browser, endpoint, body) => {
293
+ if (!browser.fetch) {
294
+ throw new Error("Tracking SDK requires fetch when sendBeacon is unavailable");
295
+ }
296
+ const response = await browser.fetch(endpoint, {
297
+ method: "POST",
298
+ keepalive: true,
299
+ headers: {
300
+ "content-type": "application/json"
301
+ },
302
+ body
303
+ });
304
+ if (!response.ok) {
305
+ throw new Error(`Tracking collect request failed with status ${response.status}`);
306
+ }
307
+ const contentType = response.headers.get("content-type") ?? "";
308
+ if (!contentType.includes("application/json")) {
309
+ return;
310
+ }
311
+ return await response.json();
312
+ };
313
+ export {
314
+ track,
315
+ reset,
316
+ page,
317
+ init,
318
+ identify,
319
+ getDebugState
320
+ };
package/dist/sdk.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { PageOptions, TrackOptions, TrackingDispatchResult, TrackingInitOptions, TrackingStateSnapshot } from "./types";
2
+ /**
3
+ * 初始化 SDK,并按默认配置发送一次 `$page_view`。
4
+ *
5
+ * @param options - SDK 初始化配置
6
+ * @returns 初始化完成后的 Promise
7
+ */
8
+ export declare const init: (options: TrackingInitOptions) => Promise<void>;
9
+ /**
10
+ * 上报自定义事件。
11
+ *
12
+ * @param eventName - 事件名
13
+ * @param options - 事件上下文与业务属性
14
+ * @returns 返回本次上报的传输结果
15
+ */
16
+ export declare const track: (eventName: string, options?: TrackOptions) => Promise<TrackingDispatchResult>;
17
+ /**
18
+ * 识别当前登录用户,仅影响后续事件。
19
+ *
20
+ * @param userId - 业务系统用户标识,传 `null` 可清除当前识别结果
21
+ */
22
+ export declare const identify: (userId: string | null) => void;
23
+ /**
24
+ * 主动发送 `$page_view` 事件。
25
+ *
26
+ * @param options - 页面上下文与附加属性
27
+ * @returns 返回本次上报的传输结果
28
+ */
29
+ export declare const page: (options?: PageOptions) => Promise<TrackingDispatchResult>;
30
+ /**
31
+ * 清空本地身份状态并生成新的匿名访客与会话。
32
+ *
33
+ * @returns 返回重置后的身份快照
34
+ */
35
+ export declare const reset: () => TrackingStateSnapshot;
36
+ /**
37
+ * 仅用于调试或测试,返回当前内存态。
38
+ *
39
+ * @returns 返回 SDK 当前状态快照
40
+ */
41
+ export declare const getDebugState: () => TrackingStateSnapshot;
@@ -0,0 +1,56 @@
1
+ export type TrackingScalar = string | number | boolean | null;
2
+ export type TrackingProperties = Record<string, TrackingScalar>;
3
+ export interface TrackingInitOptions {
4
+ writeKey: string;
5
+ endpoint: string;
6
+ autoPageview?: boolean;
7
+ sessionTimeoutMinutes?: number;
8
+ storagePrefix?: string;
9
+ }
10
+ export interface PageOptions {
11
+ path?: string;
12
+ url?: string;
13
+ title?: string;
14
+ referrer?: string;
15
+ properties?: TrackingProperties;
16
+ }
17
+ export interface TrackOptions {
18
+ eventTime?: number;
19
+ properties?: TrackingProperties;
20
+ page?: PageOptions;
21
+ }
22
+ export interface TrackingEventPayload {
23
+ event_name: string;
24
+ event_time?: number;
25
+ anonymous_id: string;
26
+ session_id: string;
27
+ user_id?: string;
28
+ page_url?: string;
29
+ page_path?: string;
30
+ page_title?: string;
31
+ referrer?: string;
32
+ utm_source?: string;
33
+ utm_medium?: string;
34
+ utm_campaign?: string;
35
+ properties: TrackingProperties;
36
+ }
37
+ export interface TrackingCollectPayload {
38
+ write_key: string;
39
+ events: TrackingEventPayload[];
40
+ }
41
+ export interface TrackingResponse {
42
+ request_id?: string;
43
+ accepted?: number;
44
+ rejected?: number;
45
+ server_time?: number;
46
+ }
47
+ export interface TrackingDispatchResult {
48
+ transport: "beacon" | "fetch";
49
+ response?: TrackingResponse;
50
+ }
51
+ export interface TrackingStateSnapshot {
52
+ anonymousId: string;
53
+ sessionId: string;
54
+ userId: string | null;
55
+ lastActivityAt: number;
56
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "rsclick-log-sdk-web",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Lightweight Web tracking SDK for rs-click-log.",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "./browser": {
17
+ "default": "./dist/index.global.js"
18
+ }
19
+ },
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "registry": "https://registry.npmjs.org/"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "README.md"
27
+ ],
28
+ "scripts": {
29
+ "build": "bun run build:esm && bun run build:browser && bun run build:types",
30
+ "build:esm": "bun build --outdir=./dist --format=esm --target=browser ./src/index.ts",
31
+ "build:browser": "bun build --outfile=./dist/index.global.js --format=iife --target=browser ./src/browser.ts",
32
+ "build:types": "npx -p typescript tsc -p tsconfig.build.json",
33
+ "test": "bun test",
34
+ "typecheck": "npx -p typescript tsc --noEmit -p tsconfig.json"
35
+ }
36
+ }