stable-deviceid 1.0.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/LICENSE +21 -0
- package/README.md +192 -0
- package/dist/base62-timestamp.d.ts +23 -0
- package/dist/base62-timestamp.js +45 -0
- package/dist/device-fingerprint.d.ts +15 -0
- package/dist/device-fingerprint.js +66 -0
- package/dist/device-id.d.ts +50 -0
- package/dist/device-id.js +142 -0
- package/dist/device-setup.d.ts +40 -0
- package/dist/device-setup.js +39 -0
- package/dist/device-sync.d.ts +73 -0
- package/dist/device-sync.js +131 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +55 -0
- package/dist/sha256.d.ts +17 -0
- package/dist/sha256.js +137 -0
- package/dist/storage.d.ts +15 -0
- package/dist/storage.js +56 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 qirly
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# stable-deviceid
|
|
2
|
+
|
|
3
|
+
跨端一致的现代浏览器设备身份库。为 Web 应用提供**稳定、可自愈、隐私友好**的设备标识:
|
|
4
|
+
结构化稳定设备 ID(生成 / 校验 / 自愈)、canvas + WebGL 设备指纹(默认关闭)、
|
|
5
|
+
HTTP 响应头身份同步、SSO 跨 origin 身份归一、axios 一站式接入。
|
|
6
|
+
|
|
7
|
+
> 设计目标:设备 ID 一经生成终身复用——用户清除缓存可自动恢复(httpOnly cookie 兜底)、
|
|
8
|
+
> 时钟偏差不引起身份漂移(±5 分钟容差)、多标签页实时同步、同设备跨应用(SSO iframe)归一为同一身份。
|
|
9
|
+
|
|
10
|
+
## 特性
|
|
11
|
+
|
|
12
|
+
- 🆔 **结构化设备 ID**:`{PLATFORM}-{ENCODED_TS}-{RANDOM}`(如 `WEB-DaBOSbNdSuc-8s4T`),
|
|
13
|
+
时间戳位混淆 + Base62 编码 + 高熵随机后缀(`crypto.getRandomValues` 拒绝采样)
|
|
14
|
+
- 🔁 **自愈能力**:损坏 / 老格式 / 过期 / 未来时间的存量 ID 自动重生;服务端校验失败自动收敛(一轮往返)
|
|
15
|
+
- 🔄 **响应头同步**:服务端可下发权威 ID(`X-Device-Id`),客户端写回后下一请求立即生效
|
|
16
|
+
- 🧩 **SSO 跨 origin 归一**:oauth21 式 iframe 登录场景,子应用采纳权威域 ID,同物理设备全端同身份
|
|
17
|
+
- 🛡 **安全内建**:与后端校验规则逐条对齐、脏值拒绝、超长头忽略、日志截断、SSO 消息双重校验
|
|
18
|
+
- 🤫 **隐私友好**:设备指纹默认关闭,需显式启用;存储层静默降级,不抛异常
|
|
19
|
+
- 📦 **零依赖**:ESM-only、tree-shaking 友好(`sideEffects: false`)、TypeScript 类型完备
|
|
20
|
+
|
|
21
|
+
## 安装
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install stable-deviceid
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
要求 Node.js >= 18(仅构建环境;运行时为现代浏览器)。
|
|
28
|
+
|
|
29
|
+
## 快速开始
|
|
30
|
+
|
|
31
|
+
### axios 一站式接入(推荐)
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import axios from 'axios';
|
|
35
|
+
import { setupDeviceSync } from 'stable-deviceid';
|
|
36
|
+
|
|
37
|
+
const http = axios.create({ baseURL: '/api', withCredentials: true });
|
|
38
|
+
setupDeviceSync(http);
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`setupDeviceSync` 自动完成:
|
|
42
|
+
|
|
43
|
+
1. 请求拦截器注入 `x-device-id` 头(内存缓存直读,近乎零开销)
|
|
44
|
+
2. 按需注入设备指纹头 `x-device-fp`(默认按环境判定,见下方指纹开关)
|
|
45
|
+
3. 响应拦截器同步服务端下发的设备 ID(写回存储 + 失效内存缓存)
|
|
46
|
+
4. 注册跨标签页 storage 监听(幂等)
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// 可选配置
|
|
50
|
+
setupDeviceSync(http, {
|
|
51
|
+
fingerprint: true, // 强制开启指纹(默认按 meta/env 判定)
|
|
52
|
+
onDeviceIdChange: (oldId, newId) => { /* 设备 ID 变更回调 */ }
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// dispose:卸载拦截器(HMR / 单测场景)
|
|
56
|
+
const dispose = setupDeviceSync(http);
|
|
57
|
+
dispose();
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 原生 fetch / 显式带头
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { getDeviceHeaders } from 'stable-deviceid';
|
|
64
|
+
|
|
65
|
+
fetch('/api/profile', { headers: { ...getDeviceHeaders() } });
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 手动拦截器(细粒度控制)
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import {
|
|
72
|
+
getStableDeviceId, // 稳定设备 ID(持久化 + 内存缓存)
|
|
73
|
+
getDeviceFingerprint, // 设备指纹(异步,32 位 hex)
|
|
74
|
+
isDeviceFingerprintEnabled, // 指纹开关判定
|
|
75
|
+
handleDeviceSyncInResponse, // 响应头同步(传 axios response)
|
|
76
|
+
initDeviceSync // 全局初始化(跨标签页监听,幂等)
|
|
77
|
+
} from 'stable-deviceid';
|
|
78
|
+
|
|
79
|
+
http.interceptors.request.use(async config => {
|
|
80
|
+
config.headers['x-device-id'] = getStableDeviceId();
|
|
81
|
+
if (isDeviceFingerprintEnabled()) {
|
|
82
|
+
try {
|
|
83
|
+
config.headers['x-device-fp'] = await getDeviceFingerprint();
|
|
84
|
+
} catch { /* 采集失败不影响主流程 */ }
|
|
85
|
+
}
|
|
86
|
+
return config;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
http.interceptors.response.use(response => {
|
|
90
|
+
handleDeviceSyncInResponse(response);
|
|
91
|
+
return response;
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## API 一览
|
|
96
|
+
|
|
97
|
+
| 导出 | 说明 |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `setupDeviceSync(instance, options?)` | 一站式接入(axios),返回 dispose 函数 |
|
|
100
|
+
| `getDeviceHeaders()` | 同步返回 `{ 'x-device-id': id }`,供 fetch / 显式带头 |
|
|
101
|
+
| `getStableDeviceId()` | 稳定设备 ID(首次生成,之后持久复用) |
|
|
102
|
+
| `getCurrentDeviceId()` / `setDeviceId(id)` / `clearDeviceId()` | 读取 / 校验写入 / 清除(均含格式校验) |
|
|
103
|
+
| `adoptDeviceId(id)` | 采纳 SSO 权威域下发的 ID(格式 + 平台段双校验,跨 origin 归一) |
|
|
104
|
+
| `validateDeviceIdFormat(id)` | 本地格式校验(返回 `{ valid, reason? }`) |
|
|
105
|
+
| `parseDeviceId(id)` | 解析 ID 信息(platform / timestamp / age),非法返回 null |
|
|
106
|
+
| `syncDeviceFromHeaders(headers, options?)` | 从响应头同步(兼容 Headers / AxiosHeaders / 普通对象) |
|
|
107
|
+
| `handleDeviceSyncInResponse(response, options?)` | 响应拦截器集成(axios / fetch Response 均可) |
|
|
108
|
+
| `initDeviceSync(options?)` | 全局初始化:跨标签页监听 + 变更回调(幂等) |
|
|
109
|
+
| `getDeviceFingerprint()` / `isDeviceFingerprintEnabled()` | 指纹采集(Promise 缓存、并发去重)与开关判定 |
|
|
110
|
+
| `sha256(message)` | SHA-256(Web Crypto 优先,非安全上下文自动降级纯 JS) |
|
|
111
|
+
| `getDeviceIdStats()` | 调试统计(当前 ID / 解析信息 / 来源) |
|
|
112
|
+
|
|
113
|
+
常量:`STORAGE_KEY`、`MAX_AGE_DAYS`(365)、`CLOCK_SKEW_TOLERANCE_MS`(±5 分钟)、
|
|
114
|
+
`DEVICE_PLATFORMS`、`RANDOM_SUFFIX_LENGTH`。
|
|
115
|
+
|
|
116
|
+
## SSO 跨 origin 身份归一
|
|
117
|
+
|
|
118
|
+
iframe 嵌入 SSO 登录页(如 oauth21)时,登录成功消息中携带权威域设备 ID,
|
|
119
|
+
子应用在 bindSession 之前采纳,同物理设备即归一为同一身份:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
window.addEventListener('message', event => {
|
|
123
|
+
// 务必校验 event.origin 与 event.source(示例省略)
|
|
124
|
+
if (event.data?.type === 'LOGIN_SUCCESS' && event.data.deviceId) {
|
|
125
|
+
adoptDeviceId(event.data.deviceId); // 包内做格式 + 平台段双校验
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## 设备指纹(默认关闭,隐私友好)
|
|
131
|
+
|
|
132
|
+
```html
|
|
133
|
+
<!-- 方式一:后端/模板注入 meta 开关 -->
|
|
134
|
+
<meta name="device-fp" content="true" />
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# 方式二:Vite 环境变量
|
|
139
|
+
VITE_DEVICE_FINGERPRINT=true
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
// 方式三:代码强制指定(非 Vite 构建器推荐)
|
|
144
|
+
setupDeviceSync(http, { fingerprint: true });
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
采集维度:canvas 渲染差异 + WebGL renderer/vendor,SHA-256 取前 32 位 hex,
|
|
148
|
+
进程内 Promise 缓存(并发去重、双失败返回空串而非常量哈希,防误匹配)。
|
|
149
|
+
|
|
150
|
+
## 安全设计
|
|
151
|
+
|
|
152
|
+
- **前后端校验对齐**:格式校验与后端 `validateDeviceId` 逐条一致(平台枚举 /
|
|
153
|
+
长度 / Base62 字符集 / 拒绝超容差未来时间 / 365 天有效期),存量非法 ID 本地即重生
|
|
154
|
+
- **脏输入防御**:响应头超 128 字符忽略、日志截断外部输入、`setDeviceId` 入口校验
|
|
155
|
+
- **缓存一致性**:任何来源的 ID 写入后立即失效内存缓存,下一请求即生效
|
|
156
|
+
- **隐私模式降级**:存储不可用时静默降级内存层(会话内稳定),不抛异常不中断请求链路
|
|
157
|
+
|
|
158
|
+
## 在 nodeServers monorepo 内开发
|
|
159
|
+
|
|
160
|
+
workspace 内前端(oauth21 / firewall / posecraft)通过 vite alias 直连源码消费:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
// vite.config.ts
|
|
164
|
+
resolve: { alias: { stable-deviceid: fileURLToPath(new URL('../packages/shared-device/src/index.ts', import.meta.url)) } }
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
// tsconfig.json
|
|
169
|
+
"paths": { "stable-deviceid": ["../packages/shared-device/src/index.ts"], "stable-deviceid/*": ["../packages/shared-device/src/*"] },
|
|
170
|
+
"include": ["src/**/*.ts", "../packages/shared-device/src/**/*.ts"]
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`package.json` dependencies 中 `"stable-deviceid": "*"` 供 workspace 链接。
|
|
174
|
+
|
|
175
|
+
## 构建与发布(维护者)
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
cd packages/shared-device
|
|
179
|
+
npm run build # esbuild 逐模块转换 → dist/ + tsc 产出 index.d.ts
|
|
180
|
+
npm publish # prepack 自动构建;ESM-only,access: public
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## 已知限制
|
|
184
|
+
|
|
185
|
+
- 隐私模式下存储降级为会话内临时 ID,每次刷新变化(console.warn 告警)
|
|
186
|
+
- `isDeviceFingerprintEnabled` 依赖 `import.meta.env`(Vite)与 DOM;非 Vite 消费方请用 meta 标签或 `fingerprint` 选项
|
|
187
|
+
- `crypto.getRandomValues` 不可用的极旧浏览器以 `Math.random` 生成随机后缀(告警提示)
|
|
188
|
+
- Safari ITP 会清除脚本可写存储,需配合服务端 httpOnly cookie 兜底恢复机制使用
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
[MIT](./LICENSE) © 2026 qirly
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* base62-timestamp.js 的类型声明(实现为纯 JS,供浏览器与 Jest 共用)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** 混淆时间戳为 Base62 字符串,返回 11 字符 */
|
|
6
|
+
export declare function encodeTimestamp(timestamp: number): string;
|
|
7
|
+
|
|
8
|
+
/** 解码 Base62 字符串为毫秒时间戳,含非法字符时抛错 */
|
|
9
|
+
export declare function decodeTimestamp(encoded: string): number;
|
|
10
|
+
|
|
11
|
+
/** 数字转 Base62 字符串(支持 BigInt) */
|
|
12
|
+
export declare function toBase62(num: number | bigint): string;
|
|
13
|
+
|
|
14
|
+
/** Base62 字符串转数字(BigInt),含非法字符时抛错 */
|
|
15
|
+
export declare function fromBase62(str: string): bigint;
|
|
16
|
+
|
|
17
|
+
export declare const BASE62_CHARS: string;
|
|
18
|
+
|
|
19
|
+
export declare const ENCODED_TS_LENGTH: number;
|
|
20
|
+
|
|
21
|
+
export declare const TS_OFFSET: bigint;
|
|
22
|
+
|
|
23
|
+
export declare const TS_MAGIC: bigint;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
2
|
+
const ENCODED_TS_LENGTH = 11;
|
|
3
|
+
const TS_OFFSET = 1704067200000n;
|
|
4
|
+
const TS_MAGIC = 0x9e3779b97f4a7c15n;
|
|
5
|
+
function toBase62(num) {
|
|
6
|
+
const n = typeof num === "bigint" ? num : BigInt(num);
|
|
7
|
+
if (n === 0n) return "0";
|
|
8
|
+
let result = "";
|
|
9
|
+
let remaining = n;
|
|
10
|
+
while (remaining > 0n) {
|
|
11
|
+
result = BASE62_CHARS[Number(remaining % 62n)] + result;
|
|
12
|
+
remaining = remaining / 62n;
|
|
13
|
+
}
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
function fromBase62(str) {
|
|
17
|
+
let result = 0n;
|
|
18
|
+
for (let i = 0; i < str.length; i++) {
|
|
19
|
+
const value = BASE62_CHARS.indexOf(str[i]);
|
|
20
|
+
if (value === -1) throw new Error(`Invalid Base62 character: ${str[i]}`);
|
|
21
|
+
result = result * 62n + BigInt(value);
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
function encodeTimestamp(timestamp) {
|
|
26
|
+
const adjusted = BigInt(timestamp) - TS_OFFSET;
|
|
27
|
+
const obfuscated = adjusted ^ TS_MAGIC;
|
|
28
|
+
const encoded = toBase62(obfuscated);
|
|
29
|
+
return encoded.padStart(ENCODED_TS_LENGTH, "0");
|
|
30
|
+
}
|
|
31
|
+
function decodeTimestamp(encoded) {
|
|
32
|
+
const obfuscated = fromBase62(encoded);
|
|
33
|
+
const adjusted = obfuscated ^ TS_MAGIC;
|
|
34
|
+
return Number(adjusted + TS_OFFSET);
|
|
35
|
+
}
|
|
36
|
+
export {
|
|
37
|
+
BASE62_CHARS,
|
|
38
|
+
ENCODED_TS_LENGTH,
|
|
39
|
+
TS_MAGIC,
|
|
40
|
+
TS_OFFSET,
|
|
41
|
+
decodeTimestamp,
|
|
42
|
+
encodeTimestamp,
|
|
43
|
+
fromBase62,
|
|
44
|
+
toBase62
|
|
45
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* device-fingerprint.js 的类型声明(实现为纯 JS,供浏览器与 Jest 共用)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 采集完整设备指纹(canvas + WebGL 合并后 SHA-256,取前 32 位 hex)
|
|
7
|
+
* 结果进程内缓存,并发调用去重;两者均采集失败返回空串
|
|
8
|
+
*/
|
|
9
|
+
export declare function getDeviceFingerprint(): Promise<string>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 是否启用设备指纹(页面 meta[name=device-fp]=true 或
|
|
13
|
+
* VITE_DEVICE_FINGERPRINT=true),默认不启用
|
|
14
|
+
*/
|
|
15
|
+
export declare function isDeviceFingerprintEnabled(): boolean;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { sha256 } from "./sha256.js";
|
|
2
|
+
let fingerprintPromise = null;
|
|
3
|
+
async function canvasFingerprint() {
|
|
4
|
+
try {
|
|
5
|
+
const canvas = document.createElement("canvas");
|
|
6
|
+
canvas.width = 240;
|
|
7
|
+
canvas.height = 60;
|
|
8
|
+
const ctx = canvas.getContext("2d");
|
|
9
|
+
if (!ctx) return "";
|
|
10
|
+
ctx.textBaseline = "top";
|
|
11
|
+
ctx.font = "14px 'Arial'";
|
|
12
|
+
ctx.fillStyle = "#f60";
|
|
13
|
+
ctx.fillRect(0, 0, 100, 30);
|
|
14
|
+
ctx.fillStyle = "#069";
|
|
15
|
+
ctx.fillText("CoreFlow device fingerprint \u{1F310}", 2, 2);
|
|
16
|
+
ctx.fillStyle = "rgba(102,204,0,0.7)";
|
|
17
|
+
ctx.fillText("CoreFlow device fingerprint \u{1F310}", 4, 4);
|
|
18
|
+
const dataUrl = canvas.toDataURL();
|
|
19
|
+
return await sha256(dataUrl);
|
|
20
|
+
} catch {
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function webglFingerprint() {
|
|
25
|
+
try {
|
|
26
|
+
const canvas = document.createElement("canvas");
|
|
27
|
+
const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
|
|
28
|
+
if (!gl) return "";
|
|
29
|
+
const debugInfo = gl.getExtension("WEBGL_debug_renderer_info");
|
|
30
|
+
if (!debugInfo) return "";
|
|
31
|
+
const vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) || "";
|
|
32
|
+
const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) || "";
|
|
33
|
+
return `${vendor}|${renderer}`;
|
|
34
|
+
} catch {
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function getDeviceFingerprint() {
|
|
39
|
+
if (!fingerprintPromise) {
|
|
40
|
+
fingerprintPromise = collectFingerprint();
|
|
41
|
+
}
|
|
42
|
+
return fingerprintPromise;
|
|
43
|
+
}
|
|
44
|
+
async function collectFingerprint() {
|
|
45
|
+
const [canvas, webgl] = await Promise.all([canvasFingerprint(), webglFingerprint()]);
|
|
46
|
+
if (!canvas && !webgl) {
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
const hash = await sha256(`${canvas}|${webgl}`);
|
|
50
|
+
return hash.slice(0, 32);
|
|
51
|
+
}
|
|
52
|
+
function isDeviceFingerprintEnabled() {
|
|
53
|
+
if (typeof document === "undefined") return false;
|
|
54
|
+
const meta = document.querySelector('meta[name="device-fp"]')?.getAttribute("content");
|
|
55
|
+
if (meta === "true") return true;
|
|
56
|
+
const env = import.meta.env || {};
|
|
57
|
+
return env.VITE_DEVICE_FINGERPRINT === "true";
|
|
58
|
+
}
|
|
59
|
+
function __resetFingerprintForTest() {
|
|
60
|
+
fingerprintPromise = null;
|
|
61
|
+
}
|
|
62
|
+
export {
|
|
63
|
+
__resetFingerprintForTest,
|
|
64
|
+
getDeviceFingerprint,
|
|
65
|
+
isDeviceFingerprintEnabled
|
|
66
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* device-id.js 的类型声明(实现为纯 JS,供浏览器与 Jest 共用)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** 设备 ID 在 localStorage 的存储键 */
|
|
6
|
+
export declare const STORAGE_KEY: string;
|
|
7
|
+
|
|
8
|
+
/** 设备 ID 最长有效期(天),与后端一致 */
|
|
9
|
+
export declare const MAX_AGE_DAYS: number;
|
|
10
|
+
|
|
11
|
+
/** 合法平台枚举 */
|
|
12
|
+
export declare const DEVICE_PLATFORMS: readonly ['WEB', 'IOS', 'ANDROID'];
|
|
13
|
+
|
|
14
|
+
/** 随机后缀长度(6 字符 Base62) */
|
|
15
|
+
export declare const RANDOM_SUFFIX_LENGTH: number;
|
|
16
|
+
|
|
17
|
+
/** 时钟偏差容差(毫秒,±5 分钟),与后端 CLOCK_SKEW_TOLERANCE_MS 一致 */
|
|
18
|
+
export declare const CLOCK_SKEW_TOLERANCE_MS: number;
|
|
19
|
+
|
|
20
|
+
/** 获取稳定 device_id(持久化 + 内存缓存,首次生成结构化 ID) */
|
|
21
|
+
export declare function getStableDeviceId(): string;
|
|
22
|
+
|
|
23
|
+
/** 使内存缓存的设备 ID 失效(下次 getStableDeviceId 重新读存储) */
|
|
24
|
+
export declare function invalidateCachedDeviceId(): void;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 校验设备 ID 格式(与后端 validateDeviceId 逐条对齐,同步版)
|
|
28
|
+
*/
|
|
29
|
+
export declare function validateDeviceIdFormat(deviceId: string): {
|
|
30
|
+
valid: boolean;
|
|
31
|
+
reason?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** 检测设备平台(与后端 detectPlatform 同规则) */
|
|
35
|
+
export declare function getPlatform(): 'WEB' | 'IOS' | 'ANDROID';
|
|
36
|
+
|
|
37
|
+
/** 设备 ID 解析结果 */
|
|
38
|
+
export interface DeviceIdInfo {
|
|
39
|
+
platform: string;
|
|
40
|
+
timestamp: number;
|
|
41
|
+
createdAt: Date;
|
|
42
|
+
/** 距今天数 */
|
|
43
|
+
age: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 调试工具:解析设备 ID 信息(宽松解析),非法返回 null */
|
|
47
|
+
export declare function parseDeviceId(deviceId: string): DeviceIdInfo | null;
|
|
48
|
+
|
|
49
|
+
/** 解码 Base64 时间戳段为毫秒时间戳(转发自 base62-timestamp) */
|
|
50
|
+
export declare function decodeTimestamp(encoded: string): number;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { BASE62_CHARS, ENCODED_TS_LENGTH, encodeTimestamp, decodeTimestamp } from "./base62-timestamp.js";
|
|
2
|
+
import { safeGetItem, safeSetItem, safeRemoveItem } from "./storage.js";
|
|
3
|
+
const STORAGE_KEY = "cf_device_id";
|
|
4
|
+
const MAX_AGE_DAYS = 365;
|
|
5
|
+
const DEVICE_PLATFORMS = ["WEB", "IOS", "ANDROID"];
|
|
6
|
+
const RANDOM_SUFFIX_LENGTH = 6;
|
|
7
|
+
const CLOCK_SKEW_TOLERANCE_MS = 5 * 60 * 1e3;
|
|
8
|
+
let cachedId = null;
|
|
9
|
+
function getStableDeviceId() {
|
|
10
|
+
if (cachedId) return cachedId;
|
|
11
|
+
let id = safeGetItem(STORAGE_KEY);
|
|
12
|
+
if (!isUsableDeviceId(id)) {
|
|
13
|
+
if (id) {
|
|
14
|
+
safeRemoveItem(STORAGE_KEY);
|
|
15
|
+
}
|
|
16
|
+
id = generateStructuredDeviceId();
|
|
17
|
+
safeSetItem(STORAGE_KEY, id);
|
|
18
|
+
}
|
|
19
|
+
cachedId = /** @type {string} */
|
|
20
|
+
id;
|
|
21
|
+
return cachedId;
|
|
22
|
+
}
|
|
23
|
+
function invalidateCachedDeviceId() {
|
|
24
|
+
cachedId = null;
|
|
25
|
+
}
|
|
26
|
+
function validateDeviceIdFormat(deviceId) {
|
|
27
|
+
if (typeof deviceId !== "string" || !deviceId) {
|
|
28
|
+
return { valid: false, reason: "\u7A7A\u503C\u6216\u975E\u5B57\u7B26\u4E32" };
|
|
29
|
+
}
|
|
30
|
+
if (deviceId.length > 64) {
|
|
31
|
+
return { valid: false, reason: "\u8D85\u957F" };
|
|
32
|
+
}
|
|
33
|
+
const parts = deviceId.split("-");
|
|
34
|
+
if (parts.length !== 3) {
|
|
35
|
+
return { valid: false, reason: "\u683C\u5F0F\u9519\u8BEF\uFF1A\u5E94\u4E3A PLATFORM-ENCODED_TS-RANDOM" };
|
|
36
|
+
}
|
|
37
|
+
const [platform, encodedTs, randomSuffix] = parts;
|
|
38
|
+
if (!DEVICE_PLATFORMS.includes(platform)) {
|
|
39
|
+
return { valid: false, reason: `\u65E0\u6548\u5E73\u53F0\uFF1A${platform}` };
|
|
40
|
+
}
|
|
41
|
+
if (encodedTs.length !== ENCODED_TS_LENGTH) {
|
|
42
|
+
return { valid: false, reason: `\u65F6\u95F4\u6233\u957F\u5EA6\u9519\u8BEF\uFF1A\u5E94\u4E3A ${ENCODED_TS_LENGTH}` };
|
|
43
|
+
}
|
|
44
|
+
if (randomSuffix.length !== RANDOM_SUFFIX_LENGTH) {
|
|
45
|
+
return { valid: false, reason: `\u968F\u673A\u540E\u7F00\u957F\u5EA6\u9519\u8BEF\uFF1A\u5E94\u4E3A ${RANDOM_SUFFIX_LENGTH}` };
|
|
46
|
+
}
|
|
47
|
+
if (!isBase62(encodedTs) || !isBase62(randomSuffix)) {
|
|
48
|
+
return { valid: false, reason: "\u5305\u542B\u975E\u6CD5\u5B57\u7B26\uFF08\u4EC5\u652F\u6301 0-9A-Za-z\uFF09" };
|
|
49
|
+
}
|
|
50
|
+
let timestamp;
|
|
51
|
+
try {
|
|
52
|
+
timestamp = decodeTimestamp(encodedTs);
|
|
53
|
+
} catch {
|
|
54
|
+
return { valid: false, reason: "\u65F6\u95F4\u6233\u89E3\u7801\u5931\u8D25" };
|
|
55
|
+
}
|
|
56
|
+
if (!Number.isFinite(timestamp)) {
|
|
57
|
+
return { valid: false, reason: "\u65F6\u95F4\u6233\u89E3\u7801\u7ED3\u679C\u975E\u6CD5" };
|
|
58
|
+
}
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
if (timestamp > now + CLOCK_SKEW_TOLERANCE_MS) {
|
|
61
|
+
return { valid: false, reason: "\u65E0\u6548\u65F6\u95F4\u6233\uFF08\u672A\u6765\u65F6\u95F4\uFF09" };
|
|
62
|
+
}
|
|
63
|
+
const ageDays = Math.floor((now - timestamp) / (1e3 * 60 * 60 * 24));
|
|
64
|
+
if (ageDays > MAX_AGE_DAYS) {
|
|
65
|
+
return { valid: false, reason: `\u8BBE\u5907 ID \u5DF2\u8FC7\u671F\uFF08\u8D85\u8FC7 ${MAX_AGE_DAYS} \u5929\uFF09` };
|
|
66
|
+
}
|
|
67
|
+
return { valid: true };
|
|
68
|
+
}
|
|
69
|
+
function isUsableDeviceId(id) {
|
|
70
|
+
if (!id) return false;
|
|
71
|
+
return validateDeviceIdFormat(id).valid;
|
|
72
|
+
}
|
|
73
|
+
function generateStructuredDeviceId() {
|
|
74
|
+
const platform = getPlatform();
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const encodedTs = encodeTimestamp(now);
|
|
77
|
+
const randomSuffix = generateBase62Random(RANDOM_SUFFIX_LENGTH);
|
|
78
|
+
return `${platform}-${encodedTs}-${randomSuffix}`;
|
|
79
|
+
}
|
|
80
|
+
function getPlatform() {
|
|
81
|
+
const ua = typeof navigator !== "undefined" ? navigator.userAgent.toLowerCase() : "";
|
|
82
|
+
if (/ipad|iphone|ipod/.test(ua)) return "IOS";
|
|
83
|
+
if (/android/.test(ua)) return "ANDROID";
|
|
84
|
+
return "WEB";
|
|
85
|
+
}
|
|
86
|
+
function generateBase62Random(length) {
|
|
87
|
+
if (typeof crypto === "undefined" || typeof crypto.getRandomValues !== "function") {
|
|
88
|
+
console.warn("\u26A0\uFE0F [DeviceId] crypto.getRandomValues \u4E0D\u53EF\u7528\uFF0C\u968F\u673A\u540E\u7F00\u964D\u7EA7\u4E3A Math.random\uFF08\u71B5\u964D\u4F4E\uFF09");
|
|
89
|
+
let fallback = "";
|
|
90
|
+
while (fallback.length < length) {
|
|
91
|
+
fallback += BASE62_CHARS[Math.floor(Math.random() * BASE62_CHARS.length)];
|
|
92
|
+
}
|
|
93
|
+
return fallback;
|
|
94
|
+
}
|
|
95
|
+
const maxUsable = Math.floor(256 / BASE62_CHARS.length) * BASE62_CHARS.length;
|
|
96
|
+
let result = "";
|
|
97
|
+
while (result.length < length) {
|
|
98
|
+
const batch = new Uint8Array(length * 2);
|
|
99
|
+
crypto.getRandomValues(batch);
|
|
100
|
+
for (let i = 0; i < batch.length && result.length < length; i++) {
|
|
101
|
+
if (batch[i] < maxUsable) {
|
|
102
|
+
result += BASE62_CHARS[batch[i] % BASE62_CHARS.length];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
function parseDeviceId(deviceId) {
|
|
109
|
+
try {
|
|
110
|
+
const parts = deviceId.split("-");
|
|
111
|
+
if (parts.length !== 3) return null;
|
|
112
|
+
const [platform, encodedTs] = parts;
|
|
113
|
+
if (encodedTs.length !== ENCODED_TS_LENGTH) return null;
|
|
114
|
+
const timestamp = decodeTimestamp(encodedTs);
|
|
115
|
+
const createdAt = new Date(timestamp);
|
|
116
|
+
const age = (Date.now() - timestamp) / (1e3 * 60 * 60 * 24);
|
|
117
|
+
return {
|
|
118
|
+
platform,
|
|
119
|
+
timestamp,
|
|
120
|
+
createdAt,
|
|
121
|
+
age: Math.floor(age)
|
|
122
|
+
};
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function isBase62(str) {
|
|
128
|
+
return /^[0-9A-Za-z]+$/.test(str);
|
|
129
|
+
}
|
|
130
|
+
export {
|
|
131
|
+
CLOCK_SKEW_TOLERANCE_MS,
|
|
132
|
+
DEVICE_PLATFORMS,
|
|
133
|
+
MAX_AGE_DAYS,
|
|
134
|
+
RANDOM_SUFFIX_LENGTH,
|
|
135
|
+
STORAGE_KEY,
|
|
136
|
+
decodeTimestamp,
|
|
137
|
+
getPlatform,
|
|
138
|
+
getStableDeviceId,
|
|
139
|
+
invalidateCachedDeviceId,
|
|
140
|
+
parseDeviceId,
|
|
141
|
+
validateDeviceIdFormat
|
|
142
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* device-setup.js 的类型声明(实现为纯 JS,供浏览器与 Jest 共用)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { DeviceSyncOptions } from './device-sync.js';
|
|
6
|
+
|
|
7
|
+
/** 最小 axios 实例结构(真 axios 实例结构兼容可直接传入) */
|
|
8
|
+
export interface AxiosLikeInstance {
|
|
9
|
+
interceptors: {
|
|
10
|
+
request: {
|
|
11
|
+
use(onFulfilled: (config: any) => any): number;
|
|
12
|
+
eject(id: number): void;
|
|
13
|
+
};
|
|
14
|
+
response: {
|
|
15
|
+
use(onFulfilled: (response: any) => any): number;
|
|
16
|
+
eject(id: number): void;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** setupDeviceSync 配置项 */
|
|
22
|
+
export interface SetupDeviceSyncOptions extends DeviceSyncOptions {
|
|
23
|
+
/**
|
|
24
|
+
* 设备指纹注入开关:true 强制注入;false 强制关闭;
|
|
25
|
+
* 缺省每请求按 isDeviceFingerprintEnabled()(meta 标签 / VITE_DEVICE_FINGERPRINT)判定
|
|
26
|
+
*/
|
|
27
|
+
fingerprint?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 一站式接入设备 ID 体系(axios 实例):注册请求/响应拦截器 + 自动 initDeviceSync
|
|
32
|
+
* @returns dispose 函数:eject 本函数注册的拦截器
|
|
33
|
+
*/
|
|
34
|
+
export declare function setupDeviceSync(
|
|
35
|
+
axiosInstance: AxiosLikeInstance,
|
|
36
|
+
options?: SetupDeviceSyncOptions
|
|
37
|
+
): () => void;
|
|
38
|
+
|
|
39
|
+
/** 获取设备 ID 请求头(同步,供 fetch 场景与显式带头使用) */
|
|
40
|
+
export declare function getDeviceHeaders(): { 'x-device-id': string };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getStableDeviceId } from "./device-id.js";
|
|
2
|
+
import { getDeviceFingerprint, isDeviceFingerprintEnabled } from "./device-fingerprint.js";
|
|
3
|
+
import { handleDeviceSyncInResponse, initDeviceSync } from "./device-sync.js";
|
|
4
|
+
const FP_HEADER = "x-device-fp";
|
|
5
|
+
function setupDeviceSync(axiosInstance, options = {}) {
|
|
6
|
+
if (!axiosInstance || !axiosInstance.interceptors?.request?.use || !axiosInstance.interceptors?.response?.use) {
|
|
7
|
+
throw new TypeError("setupDeviceSync: \u9700\u8981\u4F20\u5165 axios \u5B9E\u4F8B\uFF08\u542B interceptors.request/response.use\uFF09");
|
|
8
|
+
}
|
|
9
|
+
initDeviceSync({ onDeviceIdChange: options.onDeviceIdChange });
|
|
10
|
+
const requestInterceptorId = axiosInstance.interceptors.request.use(async (config) => {
|
|
11
|
+
if (config.headers) {
|
|
12
|
+
config.headers["x-device-id"] = getStableDeviceId();
|
|
13
|
+
const fingerprintEnabled = options.fingerprint ?? isDeviceFingerprintEnabled();
|
|
14
|
+
if (fingerprintEnabled) {
|
|
15
|
+
try {
|
|
16
|
+
const fingerprint = await getDeviceFingerprint();
|
|
17
|
+
if (fingerprint) config.headers[FP_HEADER] = fingerprint;
|
|
18
|
+
} catch {
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return config;
|
|
23
|
+
});
|
|
24
|
+
const responseInterceptorId = axiosInstance.interceptors.response.use((response) => {
|
|
25
|
+
handleDeviceSyncInResponse(response);
|
|
26
|
+
return response;
|
|
27
|
+
});
|
|
28
|
+
return function dispose() {
|
|
29
|
+
axiosInstance.interceptors.request.eject(requestInterceptorId);
|
|
30
|
+
axiosInstance.interceptors.response.eject(responseInterceptorId);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function getDeviceHeaders() {
|
|
34
|
+
return { "x-device-id": getStableDeviceId() };
|
|
35
|
+
}
|
|
36
|
+
export {
|
|
37
|
+
getDeviceHeaders,
|
|
38
|
+
setupDeviceSync
|
|
39
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* device-sync.js 的类型声明(实现为纯 JS,供浏览器与 Jest 共用)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** 设备 ID 同步选项 */
|
|
6
|
+
export interface DeviceSyncOptions {
|
|
7
|
+
/** 强制重新获取设备 ID */
|
|
8
|
+
forceRefresh?: boolean;
|
|
9
|
+
/** 设备 ID 变更回调 */
|
|
10
|
+
onDeviceIdChange?: (oldId: string, newId: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 设备 ID 解析结果(转发自 device-id) */
|
|
14
|
+
export interface DeviceIdInfo {
|
|
15
|
+
platform: string;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
createdAt: Date;
|
|
18
|
+
age: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 获取当前设备 ID(从存储读取,隐私模式降级层也可读) */
|
|
22
|
+
export declare function getCurrentDeviceId(): string | null;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 设置设备 ID 并持久化到存储(入口校验,写后失效内存缓存)
|
|
26
|
+
* @returns 是否写入成功(非法格式拒绝写入返回 false)
|
|
27
|
+
*/
|
|
28
|
+
export declare function setDeviceId(deviceId: string): boolean;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 采纳外部来源(SSO 握手)下发的权威设备 ID(跨 origin 身份归一)
|
|
32
|
+
*
|
|
33
|
+
* 双重校验(格式与后端对齐 + 平台段与本机 UA 一致)通过后走 setDeviceId。
|
|
34
|
+
* 调用方应在 bindSession/bindToken 之前调用,保证登录基准指纹与后续请求一致。
|
|
35
|
+
* @returns 是否采纳成功(校验失败返回 false,不改变本地状态)
|
|
36
|
+
*/
|
|
37
|
+
export declare function adoptDeviceId(deviceId: string): boolean;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 从响应头同步设备 ID
|
|
41
|
+
* @param headers HTTP 响应头(Headers / AxiosHeaders / 普通对象)
|
|
42
|
+
* @param options 同步选项
|
|
43
|
+
* @returns 同步后的设备 ID;响应头无有效 ID 返回 null
|
|
44
|
+
*/
|
|
45
|
+
export declare function syncDeviceFromHeaders(
|
|
46
|
+
headers: unknown,
|
|
47
|
+
options?: DeviceSyncOptions
|
|
48
|
+
): string | null;
|
|
49
|
+
|
|
50
|
+
/** HTTP 响应拦截器集成(axios 响应对象 / fetch Response 均可),原样返回响应 */
|
|
51
|
+
export declare function handleDeviceSyncInResponse<T>(response: T, options?: DeviceSyncOptions): T;
|
|
52
|
+
|
|
53
|
+
/** 初始化设备 ID 全局配置(重复调用不会重复注册监听器) */
|
|
54
|
+
export declare function initDeviceSync(options?: DeviceSyncOptions): void;
|
|
55
|
+
|
|
56
|
+
/** 清除设备 ID(存储与内存缓存同步清除) */
|
|
57
|
+
export declare function clearDeviceId(): void;
|
|
58
|
+
|
|
59
|
+
/** 设备 ID 使用统计(调试用) */
|
|
60
|
+
export declare function getDeviceIdStats(): {
|
|
61
|
+
id: string | null;
|
|
62
|
+
info: DeviceIdInfo | null;
|
|
63
|
+
source: 'localStorage' | 'none';
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
declare global {
|
|
67
|
+
interface Window {
|
|
68
|
+
/** 设备 ID 同步全局配置(initDeviceSync 写入) */
|
|
69
|
+
deviceSync?: {
|
|
70
|
+
onDeviceIdChange?: (oldId: string, newId: string) => void;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getPlatform,
|
|
3
|
+
parseDeviceId,
|
|
4
|
+
validateDeviceIdFormat,
|
|
5
|
+
invalidateCachedDeviceId,
|
|
6
|
+
STORAGE_KEY
|
|
7
|
+
} from "./device-id.js";
|
|
8
|
+
import { safeGetItem, safeSetItem, safeRemoveItem } from "./storage.js";
|
|
9
|
+
const MAX_HEADER_VALUE_LENGTH = 128;
|
|
10
|
+
const MAX_LOG_LENGTH = 48;
|
|
11
|
+
let storageListenerRegistered = false;
|
|
12
|
+
function forLog(value) {
|
|
13
|
+
const safe = String(value);
|
|
14
|
+
return safe.length > MAX_LOG_LENGTH ? `${safe.slice(0, MAX_LOG_LENGTH)}\u2026` : safe;
|
|
15
|
+
}
|
|
16
|
+
function readHeader(headers, lowerName) {
|
|
17
|
+
if (typeof headers !== "object" || headers === null) return null;
|
|
18
|
+
let value;
|
|
19
|
+
if (typeof headers.get === "function") {
|
|
20
|
+
value = headers.get(lowerName);
|
|
21
|
+
} else {
|
|
22
|
+
const pascalName = lowerName.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("-");
|
|
23
|
+
value = headers[lowerName] ?? headers[pascalName];
|
|
24
|
+
}
|
|
25
|
+
if (value === null || value === void 0) return null;
|
|
26
|
+
const str = String(value);
|
|
27
|
+
if (str.length === 0 || str.length > MAX_HEADER_VALUE_LENGTH) return null;
|
|
28
|
+
return str;
|
|
29
|
+
}
|
|
30
|
+
function getCurrentDeviceId() {
|
|
31
|
+
return safeGetItem(STORAGE_KEY);
|
|
32
|
+
}
|
|
33
|
+
function setDeviceId(deviceId) {
|
|
34
|
+
const validation = validateDeviceIdFormat(deviceId);
|
|
35
|
+
if (!validation.valid) {
|
|
36
|
+
console.warn(`\u26A0\uFE0F [DeviceSync] \u62D2\u7EDD\u5199\u5165\u975E\u6CD5\u8BBE\u5907 ID\uFF08${validation.reason}\uFF09: ${forLog(deviceId)}`);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const oldId = getCurrentDeviceId();
|
|
40
|
+
safeSetItem(STORAGE_KEY, deviceId);
|
|
41
|
+
invalidateCachedDeviceId();
|
|
42
|
+
if (oldId && oldId !== deviceId && typeof window !== "undefined") {
|
|
43
|
+
window.deviceSync?.onDeviceIdChange?.(oldId, deviceId);
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
function adoptDeviceId(deviceId) {
|
|
48
|
+
const validation = validateDeviceIdFormat(deviceId);
|
|
49
|
+
if (!validation.valid) {
|
|
50
|
+
console.warn(`\u26A0\uFE0F [DeviceSync] \u62D2\u7EDD\u91C7\u7EB3\u975E\u6CD5\u8BBE\u5907 ID\uFF08${validation.reason}\uFF09: ${forLog(deviceId)}`);
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const declaredPlatform = deviceId.split("-")[0];
|
|
54
|
+
const localPlatform = getPlatform();
|
|
55
|
+
if (declaredPlatform !== localPlatform) {
|
|
56
|
+
console.warn(`\u26A0\uFE0F [DeviceSync] \u62D2\u7EDD\u91C7\u7EB3\u5E73\u53F0\u4E0D\u4E00\u81F4\u7684\u8BBE\u5907 ID: ${forLog(declaredPlatform)} vs ${localPlatform}`);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return setDeviceId(deviceId);
|
|
60
|
+
}
|
|
61
|
+
function syncDeviceFromHeaders(headers, options = {}) {
|
|
62
|
+
const responseDeviceId = readHeader(headers, "x-device-id");
|
|
63
|
+
const hasDeviceIdUpdated = readHeader(headers, "x-device-id-updated");
|
|
64
|
+
if (!responseDeviceId) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const parsed = parseDeviceId(responseDeviceId);
|
|
68
|
+
if (!parsed) {
|
|
69
|
+
console.warn(`\u26A0\uFE0F [DeviceSync] \u65E0\u6548\u7684\u8BBE\u5907 ID \u683C\u5F0F: ${forLog(responseDeviceId)}`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const currentId = getCurrentDeviceId();
|
|
73
|
+
const shouldUpdate = options.forceRefresh || !currentId || currentId !== responseDeviceId || hasDeviceIdUpdated === "true";
|
|
74
|
+
if (shouldUpdate) {
|
|
75
|
+
setDeviceId(responseDeviceId);
|
|
76
|
+
}
|
|
77
|
+
return responseDeviceId;
|
|
78
|
+
}
|
|
79
|
+
function handleDeviceSyncInResponse(response, options) {
|
|
80
|
+
if (typeof window === "undefined" || !response?.headers) {
|
|
81
|
+
return response;
|
|
82
|
+
}
|
|
83
|
+
syncDeviceFromHeaders(response.headers, options);
|
|
84
|
+
return response;
|
|
85
|
+
}
|
|
86
|
+
function initDeviceSync(options = {}) {
|
|
87
|
+
window.deviceSync = {
|
|
88
|
+
onDeviceIdChange: options.onDeviceIdChange
|
|
89
|
+
};
|
|
90
|
+
if (storageListenerRegistered) return;
|
|
91
|
+
storageListenerRegistered = true;
|
|
92
|
+
window.addEventListener("storage", (event) => {
|
|
93
|
+
if (event.key === STORAGE_KEY && event.newValue && event.newValue !== event.oldValue) {
|
|
94
|
+
invalidateCachedDeviceId();
|
|
95
|
+
window.deviceSync?.onDeviceIdChange?.(event.oldValue ?? "", event.newValue);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function clearDeviceId() {
|
|
100
|
+
safeRemoveItem(STORAGE_KEY);
|
|
101
|
+
invalidateCachedDeviceId();
|
|
102
|
+
}
|
|
103
|
+
function getDeviceIdStats() {
|
|
104
|
+
const currentId = getCurrentDeviceId();
|
|
105
|
+
if (currentId) {
|
|
106
|
+
return {
|
|
107
|
+
id: currentId,
|
|
108
|
+
info: parseDeviceId(currentId),
|
|
109
|
+
source: "localStorage"
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
id: null,
|
|
114
|
+
info: null,
|
|
115
|
+
source: "none"
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function __resetDeviceSyncForTest() {
|
|
119
|
+
storageListenerRegistered = false;
|
|
120
|
+
}
|
|
121
|
+
export {
|
|
122
|
+
__resetDeviceSyncForTest,
|
|
123
|
+
adoptDeviceId,
|
|
124
|
+
clearDeviceId,
|
|
125
|
+
getCurrentDeviceId,
|
|
126
|
+
getDeviceIdStats,
|
|
127
|
+
handleDeviceSyncInResponse,
|
|
128
|
+
initDeviceSync,
|
|
129
|
+
setDeviceId,
|
|
130
|
+
syncDeviceFromHeaders
|
|
131
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享设备工具包入口
|
|
3
|
+
*
|
|
4
|
+
* 三前端(oauth21/posecraft/firewall)共用,保证 device_id 生成、传递、响应头同步逻辑一致。
|
|
5
|
+
*
|
|
6
|
+
* - device-id:稳定结构化设备 ID(localStorage 持久,跨账号复用)
|
|
7
|
+
* - device-fingerprint:canvas + WebGL 浏览器特征指纹(默认不启用)
|
|
8
|
+
* - device-sync:从响应头同步 device_id 到 localStorage
|
|
9
|
+
* - device-setup:一站式接入(axios 拦截器 + initDeviceSync,新前端 2 行接入)
|
|
10
|
+
* - sha256:Web Crypto API 哈希(非安全上下文降级纯 JS)
|
|
11
|
+
* - storage:localStorage 安全封装(隐私模式内存降级)
|
|
12
|
+
*
|
|
13
|
+
* 实现模块为纯 JS + 手写 .d.ts(根 Jest 纯 ESM 不编译 TS,需直接 import 测试),
|
|
14
|
+
* 本入口保持 TS 桶文件供 vite alias 消费。
|
|
15
|
+
*
|
|
16
|
+
* @author yijiu2025
|
|
17
|
+
* @since 2026-09-02
|
|
18
|
+
* @since 2026-09-04 补齐常量/校验/类型导出;实现层迁移为 .js + .d.ts
|
|
19
|
+
* @since 2026-09-05 新增 adoptDeviceId(SSO 归一采纳)与 setupDeviceSync(一站式接入)
|
|
20
|
+
*/
|
|
21
|
+
export { getStableDeviceId, invalidateCachedDeviceId, validateDeviceIdFormat, parseDeviceId, getPlatform, decodeTimestamp, STORAGE_KEY, MAX_AGE_DAYS, DEVICE_PLATFORMS, RANDOM_SUFFIX_LENGTH, CLOCK_SKEW_TOLERANCE_MS } from './device-id.js';
|
|
22
|
+
export type { DeviceIdInfo } from './device-id.js';
|
|
23
|
+
export { getDeviceFingerprint, isDeviceFingerprintEnabled } from './device-fingerprint.js';
|
|
24
|
+
export { syncDeviceFromHeaders, handleDeviceSyncInResponse, initDeviceSync, getCurrentDeviceId, setDeviceId, adoptDeviceId, clearDeviceId, getDeviceIdStats } from './device-sync.js';
|
|
25
|
+
export type { DeviceSyncOptions } from './device-sync.js';
|
|
26
|
+
export { setupDeviceSync, getDeviceHeaders } from './device-setup.js';
|
|
27
|
+
export type { AxiosLikeInstance, SetupDeviceSyncOptions } from './device-setup.js';
|
|
28
|
+
export { sha256 } from './sha256.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getStableDeviceId,
|
|
3
|
+
invalidateCachedDeviceId,
|
|
4
|
+
validateDeviceIdFormat,
|
|
5
|
+
parseDeviceId,
|
|
6
|
+
getPlatform,
|
|
7
|
+
decodeTimestamp,
|
|
8
|
+
STORAGE_KEY,
|
|
9
|
+
MAX_AGE_DAYS,
|
|
10
|
+
DEVICE_PLATFORMS,
|
|
11
|
+
RANDOM_SUFFIX_LENGTH,
|
|
12
|
+
CLOCK_SKEW_TOLERANCE_MS
|
|
13
|
+
} from "./device-id.js";
|
|
14
|
+
import {
|
|
15
|
+
getDeviceFingerprint,
|
|
16
|
+
isDeviceFingerprintEnabled
|
|
17
|
+
} from "./device-fingerprint.js";
|
|
18
|
+
import {
|
|
19
|
+
syncDeviceFromHeaders,
|
|
20
|
+
handleDeviceSyncInResponse,
|
|
21
|
+
initDeviceSync,
|
|
22
|
+
getCurrentDeviceId,
|
|
23
|
+
setDeviceId,
|
|
24
|
+
adoptDeviceId,
|
|
25
|
+
clearDeviceId,
|
|
26
|
+
getDeviceIdStats
|
|
27
|
+
} from "./device-sync.js";
|
|
28
|
+
import { setupDeviceSync, getDeviceHeaders } from "./device-setup.js";
|
|
29
|
+
import { sha256 } from "./sha256.js";
|
|
30
|
+
export {
|
|
31
|
+
CLOCK_SKEW_TOLERANCE_MS,
|
|
32
|
+
DEVICE_PLATFORMS,
|
|
33
|
+
MAX_AGE_DAYS,
|
|
34
|
+
RANDOM_SUFFIX_LENGTH,
|
|
35
|
+
STORAGE_KEY,
|
|
36
|
+
adoptDeviceId,
|
|
37
|
+
clearDeviceId,
|
|
38
|
+
decodeTimestamp,
|
|
39
|
+
getCurrentDeviceId,
|
|
40
|
+
getDeviceFingerprint,
|
|
41
|
+
getDeviceHeaders,
|
|
42
|
+
getDeviceIdStats,
|
|
43
|
+
getPlatform,
|
|
44
|
+
getStableDeviceId,
|
|
45
|
+
handleDeviceSyncInResponse,
|
|
46
|
+
initDeviceSync,
|
|
47
|
+
invalidateCachedDeviceId,
|
|
48
|
+
isDeviceFingerprintEnabled,
|
|
49
|
+
parseDeviceId,
|
|
50
|
+
setDeviceId,
|
|
51
|
+
setupDeviceSync,
|
|
52
|
+
sha256,
|
|
53
|
+
syncDeviceFromHeaders,
|
|
54
|
+
validateDeviceIdFormat
|
|
55
|
+
};
|
package/dist/sha256.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sha256.js 的类型声明(实现为纯 JS,供浏览器与 Jest 共用)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SHA-256 哈希函数
|
|
7
|
+
* @param message 待哈希字符串(UTF-8 编码)
|
|
8
|
+
* @returns 64 位小写 hex 字符串
|
|
9
|
+
*/
|
|
10
|
+
export declare function sha256(message: string): Promise<string>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 纯 JS SHA-256 降级实现(不依赖 Web Crypto)
|
|
14
|
+
* @param message 待哈希字符串
|
|
15
|
+
* @returns 64 位小写 hex 字符串
|
|
16
|
+
*/
|
|
17
|
+
export declare function sha256Pure(message: string): string;
|
package/dist/sha256.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
function hasSubtleCrypto() {
|
|
2
|
+
return typeof globalThis.crypto !== "undefined" && typeof globalThis.crypto.subtle !== "undefined" && globalThis.crypto.subtle !== null;
|
|
3
|
+
}
|
|
4
|
+
async function sha256(message) {
|
|
5
|
+
if (hasSubtleCrypto()) {
|
|
6
|
+
const msgBuffer = new TextEncoder().encode(message);
|
|
7
|
+
const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", msgBuffer);
|
|
8
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
9
|
+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
10
|
+
}
|
|
11
|
+
return sha256Pure(message);
|
|
12
|
+
}
|
|
13
|
+
const K = new Uint32Array([
|
|
14
|
+
1116352408,
|
|
15
|
+
1899447441,
|
|
16
|
+
3049323471,
|
|
17
|
+
3921009573,
|
|
18
|
+
961987163,
|
|
19
|
+
1508970993,
|
|
20
|
+
2453635748,
|
|
21
|
+
2870763221,
|
|
22
|
+
3624381080,
|
|
23
|
+
310598401,
|
|
24
|
+
607225278,
|
|
25
|
+
1426881987,
|
|
26
|
+
1925078388,
|
|
27
|
+
2162078206,
|
|
28
|
+
2614888103,
|
|
29
|
+
3248222580,
|
|
30
|
+
3835390401,
|
|
31
|
+
4022224774,
|
|
32
|
+
264347078,
|
|
33
|
+
604807628,
|
|
34
|
+
770255983,
|
|
35
|
+
1249150122,
|
|
36
|
+
1555081692,
|
|
37
|
+
1996064986,
|
|
38
|
+
2554220882,
|
|
39
|
+
2821834349,
|
|
40
|
+
2952996808,
|
|
41
|
+
3210313671,
|
|
42
|
+
3336571891,
|
|
43
|
+
3584528711,
|
|
44
|
+
113926993,
|
|
45
|
+
338241895,
|
|
46
|
+
666307205,
|
|
47
|
+
773529912,
|
|
48
|
+
1294757372,
|
|
49
|
+
1396182291,
|
|
50
|
+
1695183700,
|
|
51
|
+
1986661051,
|
|
52
|
+
2177026350,
|
|
53
|
+
2456956037,
|
|
54
|
+
2730485921,
|
|
55
|
+
2820302411,
|
|
56
|
+
3259730800,
|
|
57
|
+
3345764771,
|
|
58
|
+
3516065817,
|
|
59
|
+
3600352804,
|
|
60
|
+
4094571909,
|
|
61
|
+
275423344,
|
|
62
|
+
430227734,
|
|
63
|
+
506948616,
|
|
64
|
+
659060556,
|
|
65
|
+
883997877,
|
|
66
|
+
958139571,
|
|
67
|
+
1322822218,
|
|
68
|
+
1537002063,
|
|
69
|
+
1747873779,
|
|
70
|
+
1955562222,
|
|
71
|
+
2024104815,
|
|
72
|
+
2227730452,
|
|
73
|
+
2361852424,
|
|
74
|
+
2428436474,
|
|
75
|
+
2756734187,
|
|
76
|
+
3204031479,
|
|
77
|
+
3329325298
|
|
78
|
+
]);
|
|
79
|
+
function sha256Pure(message) {
|
|
80
|
+
const bytes = new TextEncoder().encode(message);
|
|
81
|
+
const msgLen = bytes.length;
|
|
82
|
+
const paddedLen = (msgLen + 8 >> 6) + 1 << 6;
|
|
83
|
+
const padded = new Uint8Array(paddedLen);
|
|
84
|
+
padded.set(bytes);
|
|
85
|
+
padded[msgLen] = 128;
|
|
86
|
+
const bitLenHigh = Math.floor(msgLen / 536870912);
|
|
87
|
+
const bitLenLow = msgLen << 3 >>> 0;
|
|
88
|
+
const view = new DataView(padded.buffer);
|
|
89
|
+
view.setUint32(paddedLen - 8, bitLenHigh);
|
|
90
|
+
view.setUint32(paddedLen - 4, bitLenLow);
|
|
91
|
+
let h0 = 1779033703, h1 = 3144134277, h2 = 1013904242, h3 = 2773480762;
|
|
92
|
+
let h4 = 1359893119, h5 = 2600822924, h6 = 528734635, h7 = 1541459225;
|
|
93
|
+
const w = new Uint32Array(64);
|
|
94
|
+
for (let offset = 0; offset < paddedLen; offset += 64) {
|
|
95
|
+
for (let i = 0; i < 16; i++) {
|
|
96
|
+
w[i] = view.getUint32(offset + i * 4);
|
|
97
|
+
}
|
|
98
|
+
for (let i = 16; i < 64; i++) {
|
|
99
|
+
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ w[i - 15] >>> 3;
|
|
100
|
+
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ w[i - 2] >>> 10;
|
|
101
|
+
w[i] = w[i - 16] + s0 + w[i - 7] + s1 >>> 0;
|
|
102
|
+
}
|
|
103
|
+
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7;
|
|
104
|
+
for (let i = 0; i < 64; i++) {
|
|
105
|
+
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
106
|
+
const ch = e & f ^ ~e & g;
|
|
107
|
+
const temp1 = h + S1 + ch + K[i] + w[i] >>> 0;
|
|
108
|
+
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
109
|
+
const maj = a & b ^ a & c ^ b & c;
|
|
110
|
+
const temp2 = S0 + maj >>> 0;
|
|
111
|
+
h = g;
|
|
112
|
+
g = f;
|
|
113
|
+
f = e;
|
|
114
|
+
e = d + temp1 >>> 0;
|
|
115
|
+
d = c;
|
|
116
|
+
c = b;
|
|
117
|
+
b = a;
|
|
118
|
+
a = temp1 + temp2 >>> 0;
|
|
119
|
+
}
|
|
120
|
+
h0 = h0 + a >>> 0;
|
|
121
|
+
h1 = h1 + b >>> 0;
|
|
122
|
+
h2 = h2 + c >>> 0;
|
|
123
|
+
h3 = h3 + d >>> 0;
|
|
124
|
+
h4 = h4 + e >>> 0;
|
|
125
|
+
h5 = h5 + f >>> 0;
|
|
126
|
+
h6 = h6 + g >>> 0;
|
|
127
|
+
h7 = h7 + h >>> 0;
|
|
128
|
+
}
|
|
129
|
+
return [h0, h1, h2, h3, h4, h5, h6, h7].map((x) => x.toString(16).padStart(8, "0")).join("");
|
|
130
|
+
}
|
|
131
|
+
function rotr(x, n) {
|
|
132
|
+
return (x >>> n | x << 32 - n) >>> 0;
|
|
133
|
+
}
|
|
134
|
+
export {
|
|
135
|
+
sha256,
|
|
136
|
+
sha256Pure
|
|
137
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* storage.js 的类型声明(实现为纯 JS,供浏览器与 Jest 共用)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** 读取键值,不可用时读内存降级层;不存在返回 null */
|
|
6
|
+
export declare function safeGetItem(key: string): string | null;
|
|
7
|
+
|
|
8
|
+
/** 写入键值,不可用时写内存降级层 */
|
|
9
|
+
export declare function safeSetItem(key: string, value: string): void;
|
|
10
|
+
|
|
11
|
+
/** 移除键值(localStorage 与内存降级层都清) */
|
|
12
|
+
export declare function safeRemoveItem(key: string): void;
|
|
13
|
+
|
|
14
|
+
/** 仅测试使用:重置探测缓存与内存层 */
|
|
15
|
+
export declare function __resetStorageForTest(): void;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const memoryStore = /* @__PURE__ */ new Map();
|
|
2
|
+
let localStorageAvailable = null;
|
|
3
|
+
function detectLocalStorage() {
|
|
4
|
+
try {
|
|
5
|
+
const probeKey = "__shared_device_probe__";
|
|
6
|
+
window.localStorage.setItem(probeKey, "1");
|
|
7
|
+
window.localStorage.removeItem(probeKey);
|
|
8
|
+
return true;
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function backingStorage() {
|
|
14
|
+
if (localStorageAvailable === null) {
|
|
15
|
+
localStorageAvailable = typeof window !== "undefined" && typeof window.localStorage !== "undefined" && detectLocalStorage();
|
|
16
|
+
}
|
|
17
|
+
return localStorageAvailable ? "localStorage" : "memory";
|
|
18
|
+
}
|
|
19
|
+
function safeGetItem(key) {
|
|
20
|
+
try {
|
|
21
|
+
if (backingStorage() === "localStorage") {
|
|
22
|
+
return window.localStorage.getItem(key);
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
}
|
|
26
|
+
return memoryStore.has(key) ? memoryStore.get(key) : null;
|
|
27
|
+
}
|
|
28
|
+
function safeSetItem(key, value) {
|
|
29
|
+
try {
|
|
30
|
+
if (backingStorage() === "localStorage") {
|
|
31
|
+
window.localStorage.setItem(key, value);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
}
|
|
36
|
+
memoryStore.set(key, value);
|
|
37
|
+
}
|
|
38
|
+
function safeRemoveItem(key) {
|
|
39
|
+
memoryStore.delete(key);
|
|
40
|
+
try {
|
|
41
|
+
if (typeof window !== "undefined" && window.localStorage) {
|
|
42
|
+
window.localStorage.removeItem(key);
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function __resetStorageForTest() {
|
|
48
|
+
memoryStore.clear();
|
|
49
|
+
localStorageAvailable = null;
|
|
50
|
+
}
|
|
51
|
+
export {
|
|
52
|
+
__resetStorageForTest,
|
|
53
|
+
safeGetItem,
|
|
54
|
+
safeRemoveItem,
|
|
55
|
+
safeSetItem
|
|
56
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stable-deviceid",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "跨端一致的现代浏览器设备身份库:结构化稳定设备 ID(生成/校验/自愈)、canvas+WebGL 指纹(默认关闭)、响应头身份同步、SSO 跨 origin 归一、axios 一站式接入",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"device-id",
|
|
26
|
+
"device-fingerprint",
|
|
27
|
+
"device-identity",
|
|
28
|
+
"browser-fingerprint",
|
|
29
|
+
"fraud-prevention",
|
|
30
|
+
"risk-control",
|
|
31
|
+
"sso",
|
|
32
|
+
"axios"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "node scripts/build.mjs && tsc -p tsconfig.build.json",
|
|
36
|
+
"prepack": "npm run build"
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/yijiu2025/CoreFlow.git",
|
|
41
|
+
"directory": "packages/shared-device"
|
|
42
|
+
},
|
|
43
|
+
"author": "qirly",
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public",
|
|
47
|
+
"registry": "https://registry.npmjs.org/"
|
|
48
|
+
}
|
|
49
|
+
}
|