@entmesh/sdk-miniapp 0.0.0-stage → 0.2.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 +78 -2
- package/dist/alipay.d.ts +4 -0
- package/dist/alipay.js +8 -0
- package/dist/core.d.ts +35 -0
- package/dist/core.js +198 -0
- package/dist/douyin.d.ts +4 -0
- package/dist/douyin.js +8 -0
- package/dist/hpke.d.ts +5 -0
- package/dist/hpke.js +100 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/profile-contract.d.ts +29 -0
- package/dist/profile-contract.js +1 -0
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.js +81 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/dist/wechat.d.ts +4 -0
- package/dist/wechat.js +8 -0
- package/package.json +47 -4
package/README.md
CHANGED
|
@@ -1,3 +1,79 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Entmesh Mini Program SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One privacy-first SDK for WeChat, Alipay, and Douyin mini programs. Import the runtime-specific entry, initialize before `App` and `Page` registration, grant analytics consent, and register the Collector domain in the platform console.
|
|
4
|
+
|
|
5
|
+
Install from the public npm registry:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @entmesh/sdk-miniapp --registry=https://registry.npmjs.org/
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createClient } from '@entmesh/sdk-miniapp/wechat'
|
|
13
|
+
|
|
14
|
+
const entmesh = createClient({
|
|
15
|
+
endpoint: 'https://events.example.com',
|
|
16
|
+
clientKey: 'ck_...',
|
|
17
|
+
applicationId: 'wx...',
|
|
18
|
+
consent: 'pending',
|
|
19
|
+
autocapture: true,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
await entmesh.consent('grant')
|
|
23
|
+
await entmesh.track('order_created', { order_id: 'order-1' })
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The SDK uses platform secure-random, storage, and request APIs. It never falls back to `Math.random`, never reads host-device advertising identifiers, and never sends plaintext batches.
|
|
27
|
+
|
|
28
|
+
Use `@entmesh/sdk-miniapp/alipay` for Alipay or `@entmesh/sdk-miniapp/douyin` for Douyin. Each entry exports `createClient` and the platform-independent option types.
|
|
29
|
+
|
|
30
|
+
## User properties (Proto 4.30)
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
await client.identify('user-123', { plan: 'trial' })
|
|
34
|
+
await client.setUserProperties({ plan: 'pro' })
|
|
35
|
+
await client.unsetUserProperties(['legacy_tier'])
|
|
36
|
+
await client.flush()
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Enable the profile platform and organization entitlement before using these APIs. Attribute mutations use a dedicated transport and do not create behavior events or sessions. Set merges specified top-level fields; objects and arrays replace the previous whole value. Unset removes a key; explicit null remains an empty value. Existing identify properties also populate the profile; ordinary event properties do not.
|
|
40
|
+
|
|
41
|
+
Public clients require an identified user and granted consent. Business entities and directed relationships are authored only through server APIs, after their types are registered in the project profile catalog.
|
|
42
|
+
|
|
43
|
+
## Development and releases
|
|
44
|
+
|
|
45
|
+
Node.js 22 or later is required. Tests run from a standalone checkout; the HPKE
|
|
46
|
+
interoperability fixture is included under `test/fixtures` with its protocol version.
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npm ci
|
|
50
|
+
npm test
|
|
51
|
+
npm run lint
|
|
52
|
+
npm run build
|
|
53
|
+
npm run check:package
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`package.json` is the version source. Both `npm version` and `npm run build`
|
|
57
|
+
synchronize `VERSION` and the generated runtime constant in `src/version.ts`.
|
|
58
|
+
|
|
59
|
+
Branch pushes and pull requests only validate the SDK. Pushing a stable `vX.Y.Z`
|
|
60
|
+
tag runs the release pipeline, sets the package version before testing and building,
|
|
61
|
+
and publishes `@entmesh/sdk-miniapp` publicly to npmjs.org. The first release is
|
|
62
|
+
`v0.2.0`; preparing an already-matching package version is supported. Publishing a
|
|
63
|
+
version that already exists on npm fails instead of silently skipping it.
|
|
64
|
+
|
|
65
|
+
Before pushing a release tag, create `sdk-miniapp-npm.yml` on the `main` branch of
|
|
66
|
+
the `mliev/keywords` secret repository:
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
NPM_TOKEN: "<npm granular publishing token>"
|
|
70
|
+
allow_slugs:
|
|
71
|
+
- mliev/entmesh/sdk-miniapp
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The token must have publishing access to the `@entmesh` scope and Bypass 2FA enabled
|
|
75
|
+
for automated publishing. Configure the actual value only in the secret repository.
|
|
76
|
+
Only the publish step imports it. `npm run publish:npm` checks the package, uses a
|
|
77
|
+
temporary npm configuration containing an environment-variable reference, and
|
|
78
|
+
removes that configuration on exit. The token is never written to the SDK source
|
|
79
|
+
or printed by the publishing script.
|
package/dist/alipay.d.ts
ADDED
package/dist/alipay.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { MiniProgramClient, installLifecycle } from './core.js';
|
|
2
|
+
import { runtimeAdapter } from './runtime.js';
|
|
3
|
+
export function createClient(options) {
|
|
4
|
+
const client = new MiniProgramClient(runtimeAdapter(globalThis.my, 'alipay_mini_program'), options);
|
|
5
|
+
if (options.autocapture !== false)
|
|
6
|
+
installLifecycle(client);
|
|
7
|
+
return client;
|
|
8
|
+
}
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ConsentState, MiniProgramOptions, Properties, RuntimeAdapter } from './types.js';
|
|
2
|
+
export declare class MiniProgramClient {
|
|
3
|
+
private readonly adapter;
|
|
4
|
+
private readonly options;
|
|
5
|
+
private consentState;
|
|
6
|
+
private queue;
|
|
7
|
+
private anonymous;
|
|
8
|
+
private session;
|
|
9
|
+
private user?;
|
|
10
|
+
private timer?;
|
|
11
|
+
private sending?;
|
|
12
|
+
private key?;
|
|
13
|
+
private appStartSent;
|
|
14
|
+
constructor(adapter: RuntimeAdapter, options: MiniProgramOptions);
|
|
15
|
+
private initialize;
|
|
16
|
+
track(eventName: string, properties?: Properties): Promise<void>;
|
|
17
|
+
page(route: string, properties?: Properties): Promise<void>;
|
|
18
|
+
screen(route: string, properties?: Properties): Promise<void>;
|
|
19
|
+
identify(userID: string, properties?: Properties): Promise<void>;
|
|
20
|
+
setUserProperties(properties: Properties): Promise<void>;
|
|
21
|
+
unsetUserProperties(keys: string[]): Promise<void>;
|
|
22
|
+
private profileMutation;
|
|
23
|
+
reset(): Promise<void>;
|
|
24
|
+
consent(state: ConsentState): Promise<void>;
|
|
25
|
+
refreshDeviceIdentifiers(): Promise<void>;
|
|
26
|
+
flush(): Promise<void>;
|
|
27
|
+
shutdown(): Promise<void>;
|
|
28
|
+
private capture;
|
|
29
|
+
private deliver;
|
|
30
|
+
private discovery;
|
|
31
|
+
private readQueue;
|
|
32
|
+
private persistQueue;
|
|
33
|
+
private id;
|
|
34
|
+
}
|
|
35
|
+
export declare function installLifecycle(client: MiniProgramClient): void;
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { decodeBase64url, encryptedEnvelope, sealHPKE, utf8 } from './hpke.js';
|
|
2
|
+
import { SDK_VERSION } from './version.js';
|
|
3
|
+
const ANONYMOUS_KEY = 'entmesh_anonymous_id';
|
|
4
|
+
const SESSION_KEY = 'entmesh_session';
|
|
5
|
+
const QUEUE_KEY = 'entmesh_event_queue';
|
|
6
|
+
const SESSION_TIMEOUT = 30 * 60 * 1000;
|
|
7
|
+
const QUEUE_LIMIT = 500;
|
|
8
|
+
export class MiniProgramClient {
|
|
9
|
+
constructor(adapter, options) {
|
|
10
|
+
this.adapter = adapter;
|
|
11
|
+
this.options = options;
|
|
12
|
+
this.anonymous = '';
|
|
13
|
+
this.session = { id: '', lastActivity: 0 };
|
|
14
|
+
this.appStartSent = false;
|
|
15
|
+
if (!options.clientKey.startsWith('ck_'))
|
|
16
|
+
throw new Error('clientKey must be a ck_ key');
|
|
17
|
+
if (!/^https:\/\//.test(options.endpoint) && !/^http:\/\/localhost(?::\d+)?$/.test(options.endpoint))
|
|
18
|
+
throw new Error('endpoint must use HTTPS');
|
|
19
|
+
this.consentState = options.consent ?? 'pending';
|
|
20
|
+
this.queue = this.readQueue();
|
|
21
|
+
void this.initialize();
|
|
22
|
+
}
|
|
23
|
+
async initialize() {
|
|
24
|
+
this.anonymous = String(this.adapter.getStorage(ANONYMOUS_KEY) || '') || await this.id();
|
|
25
|
+
this.adapter.setStorage(ANONYMOUS_KEY, this.anonymous);
|
|
26
|
+
const stored = this.adapter.getStorage(SESSION_KEY);
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
this.session = stored?.id && now - stored.lastActivity < SESSION_TIMEOUT ? stored : { id: await this.id(), lastActivity: now };
|
|
29
|
+
this.adapter.setStorage(SESSION_KEY, this.session);
|
|
30
|
+
this.timer = setInterval(() => { void this.flush(); }, this.options.flushIntervalMs ?? 5000);
|
|
31
|
+
}
|
|
32
|
+
async track(eventName, properties = {}) { await this.capture('track', eventName, properties); }
|
|
33
|
+
async page(route, properties = {}) { await this.capture('page', '$page_view', { route, ...properties }); }
|
|
34
|
+
async screen(route, properties = {}) { await this.page(route, properties); }
|
|
35
|
+
async identify(userID, properties = {}) {
|
|
36
|
+
const value = userID.trim();
|
|
37
|
+
if (!value)
|
|
38
|
+
throw new Error('userID is required');
|
|
39
|
+
this.user = value;
|
|
40
|
+
await this.capture('identify', '$identify', properties);
|
|
41
|
+
}
|
|
42
|
+
async setUserProperties(properties) { await this.profileMutation('set', properties); }
|
|
43
|
+
async unsetUserProperties(keys) { await this.profileMutation('unset', undefined, keys); }
|
|
44
|
+
async profileMutation(operation, properties, unset) {
|
|
45
|
+
if (this.consentState !== 'grant')
|
|
46
|
+
return;
|
|
47
|
+
const user = this.user;
|
|
48
|
+
if (!user)
|
|
49
|
+
throw new Error('identify a user before updating properties');
|
|
50
|
+
if (this.queue.length >= QUEUE_LIMIT)
|
|
51
|
+
throw new Error('profile queue is full');
|
|
52
|
+
this.queue.push({ mutation_id: await this.id(), occurred_at: new Date().toISOString(), subject: { kind: 'user', id: user }, operation, ...(properties ? { properties: JSON.parse(JSON.stringify(properties)) } : {}), ...(unset ? { unset: [...unset] } : {}) });
|
|
53
|
+
this.persistQueue();
|
|
54
|
+
if (this.queue.length >= (this.options.batchSize ?? 20))
|
|
55
|
+
await this.flush();
|
|
56
|
+
}
|
|
57
|
+
async reset() {
|
|
58
|
+
this.user = undefined;
|
|
59
|
+
this.anonymous = await this.id();
|
|
60
|
+
this.session = { id: await this.id(), lastActivity: Date.now() };
|
|
61
|
+
this.adapter.setStorage(ANONYMOUS_KEY, this.anonymous);
|
|
62
|
+
this.adapter.setStorage(SESSION_KEY, this.session);
|
|
63
|
+
}
|
|
64
|
+
async consent(state) {
|
|
65
|
+
this.consentState = state;
|
|
66
|
+
if (state === 'deny' || state === 'revoke') {
|
|
67
|
+
this.queue = [];
|
|
68
|
+
this.persistQueue();
|
|
69
|
+
if (state === 'revoke') {
|
|
70
|
+
this.adapter.removeStorage(ANONYMOUS_KEY);
|
|
71
|
+
this.adapter.removeStorage(SESSION_KEY);
|
|
72
|
+
await this.reset();
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (state === 'grant') {
|
|
77
|
+
if (!this.appStartSent) {
|
|
78
|
+
this.appStartSent = true;
|
|
79
|
+
await this.capture('track', '$app_start', {});
|
|
80
|
+
}
|
|
81
|
+
await this.flush();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async refreshDeviceIdentifiers() { }
|
|
85
|
+
async flush() {
|
|
86
|
+
if (this.consentState !== 'grant' || this.queue.length === 0)
|
|
87
|
+
return;
|
|
88
|
+
if (this.sending)
|
|
89
|
+
return this.sending;
|
|
90
|
+
this.sending = (async () => { let remaining = this.queue.length; while (remaining > 0 && this.consentState === 'grant') {
|
|
91
|
+
const before = this.queue.length;
|
|
92
|
+
await this.deliver();
|
|
93
|
+
const sent = before - this.queue.length;
|
|
94
|
+
if (sent <= 0)
|
|
95
|
+
break;
|
|
96
|
+
remaining -= sent;
|
|
97
|
+
} })().finally(() => { this.sending = undefined; });
|
|
98
|
+
return this.sending;
|
|
99
|
+
}
|
|
100
|
+
async shutdown() { if (this.timer)
|
|
101
|
+
clearInterval(this.timer); await this.flush(); }
|
|
102
|
+
async capture(type, eventName, properties) {
|
|
103
|
+
if (this.consentState !== 'grant')
|
|
104
|
+
return;
|
|
105
|
+
if (!/^[$A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(eventName))
|
|
106
|
+
throw new Error('event name is invalid');
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
if (!this.session.id || now - this.session.lastActivity >= SESSION_TIMEOUT)
|
|
109
|
+
this.session = { id: await this.id(), lastActivity: now };
|
|
110
|
+
this.session.lastActivity = now;
|
|
111
|
+
this.adapter.setStorage(SESSION_KEY, this.session);
|
|
112
|
+
const event = {
|
|
113
|
+
message_id: await this.id(), type, event_name: eventName, occurred_at: new Date(now).toISOString(),
|
|
114
|
+
anonymous_id: this.anonymous || await this.id(), session_id: this.session.id, properties,
|
|
115
|
+
context: { sdk: { name: 'entmesh-miniapp', version: SDK_VERSION }, device: this.adapter.systemContext() },
|
|
116
|
+
};
|
|
117
|
+
if (this.user)
|
|
118
|
+
event.user_id = this.user;
|
|
119
|
+
if (this.queue.length >= QUEUE_LIMIT)
|
|
120
|
+
this.queue.shift();
|
|
121
|
+
this.queue.push(event);
|
|
122
|
+
this.persistQueue();
|
|
123
|
+
if (this.queue.length >= (this.options.batchSize ?? 20))
|
|
124
|
+
await this.flush();
|
|
125
|
+
}
|
|
126
|
+
async deliver() {
|
|
127
|
+
const profile = 'mutation_id' in this.queue[0];
|
|
128
|
+
const events = this.queue.slice(0, this.options.batchSize ?? 20).filter((item, index, items) => items.slice(0, index + 1).every((record) => ('mutation_id' in record) === profile));
|
|
129
|
+
const batchID = await this.id();
|
|
130
|
+
const batch = JSON.stringify({
|
|
131
|
+
schema_version: '1', batch_id: batchID, sent_at: new Date().toISOString(),
|
|
132
|
+
client: { platform: this.adapter.platform, application_id: this.options.applicationId, app_version: this.options.appVersion, sdk_name: 'entmesh-miniapp', sdk_version: SDK_VERSION },
|
|
133
|
+
...(profile ? { mutations: events } : { events }),
|
|
134
|
+
});
|
|
135
|
+
try {
|
|
136
|
+
const discovery = await this.discovery();
|
|
137
|
+
const info = utf8(`entmesh-client-${profile ? "profile" : "batch"}-v1\0${discovery.kid}\0${this.options.clientKey}`);
|
|
138
|
+
const ciphertext = await sealHPKE(decodeBase64url(discovery.public_key), info, utf8(batch), (length) => this.adapter.randomBytes(length));
|
|
139
|
+
const response = await this.adapter.request({ url: `${this.options.endpoint.replace(/\/$/, '')}/v1/client/${profile ? "profiles/" : ""}batch/${this.options.clientKey}`, method: 'POST', data: JSON.parse(encryptedEnvelope(discovery.kid, ciphertext)), headers: { 'content-type': 'application/json' } });
|
|
140
|
+
if (response.statusCode === 409) {
|
|
141
|
+
this.key = undefined;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (response.statusCode < 200 || response.statusCode >= 300)
|
|
145
|
+
return;
|
|
146
|
+
this.queue.splice(0, events.length);
|
|
147
|
+
this.persistQueue();
|
|
148
|
+
}
|
|
149
|
+
catch { /* Retain the persistent queue for the next bounded flush attempt. */ }
|
|
150
|
+
}
|
|
151
|
+
async discovery() {
|
|
152
|
+
if (this.key && this.key.expiresAt > Date.now())
|
|
153
|
+
return this.key.value;
|
|
154
|
+
const response = await this.adapter.request({ url: `${this.options.endpoint.replace(/\/$/, '')}/v1/browser-encryption-key`, method: 'GET' });
|
|
155
|
+
if (response.statusCode !== 200 || !response.data || typeof response.data !== 'object')
|
|
156
|
+
throw new Error('encryption key discovery failed');
|
|
157
|
+
const value = response.data;
|
|
158
|
+
if (value.version !== '1' || !value.kid || !value.public_key || value.suite?.kem !== 'DHKEM_P256_HKDF_SHA256' || value.suite?.kdf !== 'HKDF_SHA256' || value.suite?.aead !== 'AES_256_GCM')
|
|
159
|
+
throw new Error('encryption key discovery is invalid');
|
|
160
|
+
this.key = { value, expiresAt: Date.now() + 5 * 60 * 1000 };
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
readQueue() { const value = this.adapter.getStorage(QUEUE_KEY); return Array.isArray(value) ? value.slice(-QUEUE_LIMIT) : []; }
|
|
164
|
+
persistQueue() { this.adapter.setStorage(QUEUE_KEY, this.queue); }
|
|
165
|
+
async id() { return Array.from(await this.adapter.randomBytes(16), (value) => value.toString(16).padStart(2, '0')).join(''); }
|
|
166
|
+
}
|
|
167
|
+
export function installLifecycle(client) {
|
|
168
|
+
const root = globalThis;
|
|
169
|
+
const wrap = (name) => {
|
|
170
|
+
const original = root[name];
|
|
171
|
+
if (typeof original !== 'function' || original.__entmesh)
|
|
172
|
+
return;
|
|
173
|
+
const wrapped = (options) => {
|
|
174
|
+
const next = { ...options };
|
|
175
|
+
const handlers = name === 'App'
|
|
176
|
+
? { onLaunch: '$app_start', onShow: '$app_foreground', onHide: '$app_background' }
|
|
177
|
+
: { onShow: '$page_view', onHide: '$visibility', onUnload: '$page_exit' };
|
|
178
|
+
for (const [handler, eventName] of Object.entries(handlers)) {
|
|
179
|
+
const existing = next[handler];
|
|
180
|
+
next[handler] = function (...args) {
|
|
181
|
+
const route = String(this.route || this.__route__ || '');
|
|
182
|
+
if (eventName === '$page_view')
|
|
183
|
+
void client.page(route);
|
|
184
|
+
else
|
|
185
|
+
void client.track(eventName, route ? { route } : {});
|
|
186
|
+
if (eventName === '$app_background')
|
|
187
|
+
void client.flush();
|
|
188
|
+
return typeof existing === 'function' ? existing.apply(this, args) : undefined;
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return original(next);
|
|
192
|
+
};
|
|
193
|
+
wrapped.__entmesh = true;
|
|
194
|
+
root[name] = wrapped;
|
|
195
|
+
};
|
|
196
|
+
wrap('App');
|
|
197
|
+
wrap('Page');
|
|
198
|
+
}
|
package/dist/douyin.d.ts
ADDED
package/dist/douyin.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { MiniProgramClient, installLifecycle } from './core.js';
|
|
2
|
+
import { runtimeAdapter } from './runtime.js';
|
|
3
|
+
export function createClient(options) {
|
|
4
|
+
const client = new MiniProgramClient(runtimeAdapter(globalThis.tt, 'douyin_mini_program'), options);
|
|
5
|
+
if (options.autocapture !== false)
|
|
6
|
+
installLifecycle(client);
|
|
7
|
+
return client;
|
|
8
|
+
}
|
package/dist/hpke.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function utf8(value: string): Uint8Array;
|
|
2
|
+
export declare function concat(...values: Uint8Array[]): Uint8Array;
|
|
3
|
+
export declare function decodeBase64url(value: string): Uint8Array;
|
|
4
|
+
export declare function sealHPKE(recipientPublicKey: Uint8Array, info: Uint8Array, plaintext: Uint8Array, randomBytes: (length: number) => Promise<Uint8Array>): Promise<Uint8Array>;
|
|
5
|
+
export declare function encryptedEnvelope(kid: string, ciphertext: Uint8Array): string;
|
package/dist/hpke.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { gcm } from '@noble/ciphers/aes.js';
|
|
2
|
+
import { p256 } from '@noble/curves/nist.js';
|
|
3
|
+
import { expand, extract } from '@noble/hashes/hkdf.js';
|
|
4
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
5
|
+
const VERSION = utf8('HPKE-v1');
|
|
6
|
+
const KEM_SUITE = concat(utf8('KEM'), i2osp(0x0010, 2));
|
|
7
|
+
const HPKE_SUITE = concat(utf8('HPKE'), i2osp(0x0010, 2), i2osp(0x0001, 2), i2osp(0x0002, 2));
|
|
8
|
+
export function utf8(value) {
|
|
9
|
+
const encoded = unescape(encodeURIComponent(value));
|
|
10
|
+
return Uint8Array.from(encoded, (character) => character.charCodeAt(0));
|
|
11
|
+
}
|
|
12
|
+
export function concat(...values) {
|
|
13
|
+
const output = new Uint8Array(values.reduce((sum, value) => sum + value.length, 0));
|
|
14
|
+
let offset = 0;
|
|
15
|
+
for (const value of values) {
|
|
16
|
+
output.set(value, offset);
|
|
17
|
+
offset += value.length;
|
|
18
|
+
}
|
|
19
|
+
return output;
|
|
20
|
+
}
|
|
21
|
+
function i2osp(value, length) {
|
|
22
|
+
const result = new Uint8Array(length);
|
|
23
|
+
for (let index = length - 1; index >= 0; index--) {
|
|
24
|
+
result[index] = value & 0xff;
|
|
25
|
+
value >>>= 8;
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
function labeledExtract(suite, salt, label, ikm) {
|
|
30
|
+
return extract(sha256, concat(VERSION, suite, utf8(label), ikm), salt);
|
|
31
|
+
}
|
|
32
|
+
function labeledExpand(suite, prk, label, info, length) {
|
|
33
|
+
return expand(sha256, prk, concat(i2osp(length, 2), VERSION, suite, utf8(label), info), length);
|
|
34
|
+
}
|
|
35
|
+
function base64url(bytes) {
|
|
36
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
37
|
+
let output = '';
|
|
38
|
+
for (let offset = 0; offset < bytes.length; offset += 3) {
|
|
39
|
+
const a = bytes[offset] ?? 0, b = bytes[offset + 1] ?? 0, c = bytes[offset + 2] ?? 0;
|
|
40
|
+
const value = (a << 16) | (b << 8) | c;
|
|
41
|
+
output += alphabet[(value >>> 18) & 63] + alphabet[(value >>> 12) & 63];
|
|
42
|
+
if (offset + 1 < bytes.length)
|
|
43
|
+
output += alphabet[(value >>> 6) & 63];
|
|
44
|
+
if (offset + 2 < bytes.length)
|
|
45
|
+
output += alphabet[value & 63];
|
|
46
|
+
}
|
|
47
|
+
return output;
|
|
48
|
+
}
|
|
49
|
+
export function decodeBase64url(value) {
|
|
50
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value))
|
|
51
|
+
throw new Error('invalid base64url');
|
|
52
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
53
|
+
const output = [];
|
|
54
|
+
let buffer = 0, bits = 0;
|
|
55
|
+
for (const character of value) {
|
|
56
|
+
const index = alphabet.indexOf(character);
|
|
57
|
+
if (index < 0)
|
|
58
|
+
throw new Error('invalid base64url');
|
|
59
|
+
buffer = (buffer << 6) | index;
|
|
60
|
+
bits += 6;
|
|
61
|
+
if (bits >= 8) {
|
|
62
|
+
bits -= 8;
|
|
63
|
+
output.push((buffer >>> bits) & 0xff);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return Uint8Array.from(output);
|
|
67
|
+
}
|
|
68
|
+
export async function sealHPKE(recipientPublicKey, info, plaintext, randomBytes) {
|
|
69
|
+
if (recipientPublicKey.length !== 65 || recipientPublicKey[0] !== 4)
|
|
70
|
+
throw new Error('invalid recipient public key');
|
|
71
|
+
const ikm = await randomBytes(32);
|
|
72
|
+
const dkpPRK = labeledExtract(KEM_SUITE, new Uint8Array(), 'dkp_prk', ikm);
|
|
73
|
+
let secretKey;
|
|
74
|
+
for (let counter = 0; counter < 256 && !secretKey; counter++) {
|
|
75
|
+
const candidate = labeledExpand(KEM_SUITE, dkpPRK, 'candidate', Uint8Array.of(counter), 32);
|
|
76
|
+
try {
|
|
77
|
+
p256.getPublicKey(candidate, false);
|
|
78
|
+
secretKey = candidate;
|
|
79
|
+
}
|
|
80
|
+
catch { /* try the next deterministic candidate */ }
|
|
81
|
+
}
|
|
82
|
+
if (!secretKey)
|
|
83
|
+
throw new Error('secure random private key generation failed');
|
|
84
|
+
const enc = p256.getPublicKey(secretKey, false);
|
|
85
|
+
const dhPoint = p256.getSharedSecret(secretKey, recipientPublicKey, false);
|
|
86
|
+
const dh = dhPoint.slice(1, 33);
|
|
87
|
+
const kemContext = concat(enc, recipientPublicKey);
|
|
88
|
+
const eaePRK = labeledExtract(KEM_SUITE, new Uint8Array(), 'eae_prk', dh);
|
|
89
|
+
const sharedSecret = labeledExpand(KEM_SUITE, eaePRK, 'shared_secret', kemContext, 32);
|
|
90
|
+
const pskIDHash = labeledExtract(HPKE_SUITE, new Uint8Array(), 'psk_id_hash', new Uint8Array());
|
|
91
|
+
const infoHash = labeledExtract(HPKE_SUITE, new Uint8Array(), 'info_hash', info);
|
|
92
|
+
const keyScheduleContext = concat(Uint8Array.of(0), pskIDHash, infoHash);
|
|
93
|
+
const secret = labeledExtract(HPKE_SUITE, sharedSecret, 'secret', new Uint8Array());
|
|
94
|
+
const key = labeledExpand(HPKE_SUITE, secret, 'key', keyScheduleContext, 32);
|
|
95
|
+
const nonce = labeledExpand(HPKE_SUITE, secret, 'base_nonce', keyScheduleContext, 12);
|
|
96
|
+
return concat(enc, gcm(key, nonce, new Uint8Array()).encrypt(plaintext));
|
|
97
|
+
}
|
|
98
|
+
export function encryptedEnvelope(kid, ciphertext) {
|
|
99
|
+
return JSON.stringify({ version: '1', kid, ciphertext: base64url(ciphertext) });
|
|
100
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MiniProgramClient } from './core.js';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { PropertyValue } from './types.js';
|
|
2
|
+
export interface ProfileSubject {
|
|
3
|
+
kind: 'user' | 'entity';
|
|
4
|
+
id: string;
|
|
5
|
+
entity_type?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ProfileMutation {
|
|
8
|
+
mutation_id: string;
|
|
9
|
+
occurred_at: string;
|
|
10
|
+
subject: ProfileSubject;
|
|
11
|
+
operation: 'set' | 'unset' | 'entity_upsert' | 'link' | 'unlink';
|
|
12
|
+
properties?: Record<string, PropertyValue>;
|
|
13
|
+
unset?: string[];
|
|
14
|
+
relation_type?: string;
|
|
15
|
+
target?: ProfileSubject;
|
|
16
|
+
}
|
|
17
|
+
export interface ProfileBatch {
|
|
18
|
+
schema_version: '1';
|
|
19
|
+
batch_id: string;
|
|
20
|
+
sent_at: string;
|
|
21
|
+
client?: {
|
|
22
|
+
platform: string;
|
|
23
|
+
application_id: string;
|
|
24
|
+
app_version?: string;
|
|
25
|
+
sdk_name: string;
|
|
26
|
+
sdk_version: string;
|
|
27
|
+
};
|
|
28
|
+
mutations: ProfileMutation[];
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
function callRequest(runtime, options) {
|
|
2
|
+
return new Promise((resolve, reject) => {
|
|
3
|
+
const request = runtime.request;
|
|
4
|
+
if (typeof request !== 'function') {
|
|
5
|
+
reject(new Error('mini-program request API is unavailable'));
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
request.call(runtime, {
|
|
9
|
+
url: options.url, method: options.method, data: options.data,
|
|
10
|
+
header: options.headers, headers: options.headers,
|
|
11
|
+
dataType: options.method === 'GET' ? 'json' : 'text',
|
|
12
|
+
success: (result) => resolve({ statusCode: Number(result.statusCode ?? result.status ?? 0), data: result.data }),
|
|
13
|
+
fail: reject,
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function secureRandom(runtime, length) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const getRandomValues = runtime.getRandomValues;
|
|
20
|
+
if (typeof getRandomValues !== 'function') {
|
|
21
|
+
reject(new Error('secure random API is unavailable'));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
getRandomValues.call(runtime, {
|
|
25
|
+
length,
|
|
26
|
+
success: (result) => {
|
|
27
|
+
const value = result.randomValues ?? result.data;
|
|
28
|
+
if (value instanceof ArrayBuffer)
|
|
29
|
+
resolve(new Uint8Array(value));
|
|
30
|
+
else if (ArrayBuffer.isView(value))
|
|
31
|
+
resolve(new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
|
|
32
|
+
else
|
|
33
|
+
reject(new Error('secure random API returned invalid bytes'));
|
|
34
|
+
},
|
|
35
|
+
fail: reject,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export function runtimeAdapter(runtime, platform) {
|
|
40
|
+
return {
|
|
41
|
+
platform,
|
|
42
|
+
request: (options) => callRequest(runtime, options),
|
|
43
|
+
getStorage(key) {
|
|
44
|
+
const value = typeof runtime.getStorageSync === 'function'
|
|
45
|
+
? runtime.getStorageSync.call(runtime, (platform === 'alipay_mini_program' ? { key } : key))
|
|
46
|
+
: undefined;
|
|
47
|
+
if (value && typeof value === 'object' && 'data' in value)
|
|
48
|
+
return value.data;
|
|
49
|
+
return value;
|
|
50
|
+
},
|
|
51
|
+
setStorage(key, value) {
|
|
52
|
+
if (typeof runtime.setStorageSync !== 'function')
|
|
53
|
+
return;
|
|
54
|
+
if (platform === 'alipay_mini_program')
|
|
55
|
+
runtime.setStorageSync.call(runtime, { key, data: value });
|
|
56
|
+
else
|
|
57
|
+
runtime.setStorageSync.call(runtime, key, value);
|
|
58
|
+
},
|
|
59
|
+
removeStorage(key) {
|
|
60
|
+
if (typeof runtime.removeStorageSync !== 'function')
|
|
61
|
+
return;
|
|
62
|
+
if (platform === 'alipay_mini_program')
|
|
63
|
+
runtime.removeStorageSync.call(runtime, { key });
|
|
64
|
+
else
|
|
65
|
+
runtime.removeStorageSync.call(runtime, key);
|
|
66
|
+
},
|
|
67
|
+
randomBytes: (length) => secureRandom(runtime, length),
|
|
68
|
+
systemContext() {
|
|
69
|
+
for (const name of ['getDeviceInfo', 'getDeviceInfoSync', 'getSystemInfoSync']) {
|
|
70
|
+
const method = runtime[name];
|
|
71
|
+
if (typeof method === 'function') {
|
|
72
|
+
try {
|
|
73
|
+
return method.call(runtime);
|
|
74
|
+
}
|
|
75
|
+
catch { /* try the next supported API */ }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return {};
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type ConsentState = 'pending' | 'grant' | 'deny' | 'revoke';
|
|
2
|
+
export type MiniProgramPlatform = 'wechat_mini_program' | 'alipay_mini_program' | 'douyin_mini_program';
|
|
3
|
+
export type PropertyScalar = string | number | boolean | null;
|
|
4
|
+
export type PropertyValue = PropertyScalar | PropertyScalar[] | Record<string, PropertyScalar>;
|
|
5
|
+
export type Properties = Record<string, PropertyValue>;
|
|
6
|
+
export interface MiniProgramOptions {
|
|
7
|
+
endpoint: string;
|
|
8
|
+
clientKey: string;
|
|
9
|
+
applicationId: string;
|
|
10
|
+
appVersion?: string;
|
|
11
|
+
consent?: ConsentState;
|
|
12
|
+
autocapture?: boolean;
|
|
13
|
+
flushIntervalMs?: number;
|
|
14
|
+
batchSize?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface EventV1 {
|
|
17
|
+
message_id: string;
|
|
18
|
+
type: 'page' | 'track' | 'identify';
|
|
19
|
+
event_name: string;
|
|
20
|
+
occurred_at: string;
|
|
21
|
+
anonymous_id: string;
|
|
22
|
+
user_id?: string;
|
|
23
|
+
session_id: string;
|
|
24
|
+
properties: Properties;
|
|
25
|
+
context: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
export interface RuntimeResponse {
|
|
28
|
+
statusCode: number;
|
|
29
|
+
data: unknown;
|
|
30
|
+
}
|
|
31
|
+
export interface RuntimeAdapter {
|
|
32
|
+
readonly platform: MiniProgramPlatform;
|
|
33
|
+
request(options: {
|
|
34
|
+
url: string;
|
|
35
|
+
method: 'GET' | 'POST';
|
|
36
|
+
data?: unknown;
|
|
37
|
+
headers?: Record<string, string>;
|
|
38
|
+
}): Promise<RuntimeResponse>;
|
|
39
|
+
getStorage(key: string): unknown;
|
|
40
|
+
setStorage(key: string, value: unknown): void;
|
|
41
|
+
removeStorage(key: string): void;
|
|
42
|
+
randomBytes(length: number): Promise<Uint8Array>;
|
|
43
|
+
systemContext(): Record<string, unknown>;
|
|
44
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const SDK_VERSION = "0.2.0";
|
package/dist/version.js
ADDED
package/dist/wechat.d.ts
ADDED
package/dist/wechat.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { MiniProgramClient, installLifecycle } from './core.js';
|
|
2
|
+
import { runtimeAdapter } from './runtime.js';
|
|
3
|
+
export function createClient(options) {
|
|
4
|
+
const client = new MiniProgramClient(runtimeAdapter(globalThis.wx, 'wechat_mini_program'), options);
|
|
5
|
+
if (options.autocapture !== false)
|
|
6
|
+
installLifecycle(client);
|
|
7
|
+
return client;
|
|
8
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entmesh/sdk-miniapp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
"./wechat": {
|
|
9
|
+
"types": "./dist/wechat.d.ts",
|
|
10
|
+
"import": "./dist/wechat.js"
|
|
11
|
+
},
|
|
12
|
+
"./alipay": {
|
|
13
|
+
"types": "./dist/alipay.d.ts",
|
|
14
|
+
"import": "./dist/alipay.js"
|
|
15
|
+
},
|
|
16
|
+
"./douyin": {
|
|
17
|
+
"types": "./dist/douyin.d.ts",
|
|
18
|
+
"import": "./dist/douyin.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"registry": "https://registry.npmjs.org/",
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "node scripts/sync-version.mjs && tsc -p tsconfig.json",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"lint": "tsc --noEmit",
|
|
33
|
+
"version": "node scripts/sync-version.mjs",
|
|
34
|
+
"check:package": "node scripts/check-package.mjs",
|
|
35
|
+
"publish:npm": "node scripts/publish.mjs"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@noble/ciphers": "^2.0.1",
|
|
39
|
+
"@noble/curves": "^2.0.1",
|
|
40
|
+
"@noble/hashes": "^2.0.1"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"typescript": "^5.8.3",
|
|
44
|
+
"vitest": "^3.2.4"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=22"
|
|
48
|
+
}
|
|
49
|
+
}
|