-
Notifications
You must be signed in to change notification settings - Fork 7
feat(amsg): client 新增 client_state 三方法,对接单用户 worker 云端状态镜像 #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| --- | ||
| "@rei-standard/amsg-client": minor | ||
| --- | ||
|
|
||
| 新增 client_state 三方法,对接单用户 worker 的云端状态镜像(amsg-server 2.6.0 的 `/client-state` 端点)。 | ||
|
|
||
| - `putClientState(entries)`:批量 upsert。一次请求发完全部变更(照顾 iOS 切后台前只有几秒的存活窗口);请求体走既有加密链路(需先 `init()`),服务端按 `updatedAt` 最后写赢,重发旧批次无害。 | ||
| - `getClientState(namespace)`:取一个 namespace 的全部条目,自动解密响应 envelope(同 `listMessages`)。 | ||
| - `clearClientState()`:清空该用户全部云端状态(给设置页「清除云端状态」这类入口用)。 | ||
|
|
||
| 配了 `serverToken` 时三个方法都带 `X-Client-Token`。 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -990,6 +990,104 @@ export class ReiClient { | |
| }; | ||
| } | ||
|
|
||
| // ─── Client state (single-user cloud mirror) ──────────────────── | ||
|
|
||
| /** | ||
| * Batch-upsert client-state entries (single-user worker, | ||
| * amsg-server 2.6.0+ `/client-state`). | ||
| * | ||
| * The worker keeps one live copy per (namespace, key); this client is | ||
| * the only writer. Send everything that changed in ONE call — e.g. | ||
| * inside the few-seconds window before iOS backgrounds the page — the | ||
| * server upserts the whole batch in a single DB round trip. Upserts | ||
| * are last-write-wins on `updatedAt`: entries older than the stored | ||
| * row are skipped, so re-sending a stale batch is harmless. | ||
| * | ||
| * The payload is encrypted like every other amsg-server call | ||
| * (requires `init()`); the worker re-encrypts each value at rest | ||
| * under the per-user key. | ||
| * | ||
| * @param {Array<{ namespace: string, key: string, value: string, updatedAt: number }>} entries | ||
| * - `value`: pre-serialized string (the SDK does not stringify it for you). | ||
| * - `updatedAt`: epoch milliseconds. | ||
| * @returns {Promise<Object>} `{ success, data?: { upserted, skipped }, error? }` | ||
| */ | ||
| async putClientState(entries) { | ||
| if (!Array.isArray(entries) || entries.length === 0) { | ||
| throw new TypeError('[rei-standard-amsg-client] entries must be a non-empty array'); | ||
| } | ||
| const json = JSON.stringify({ entries }); | ||
| this._assertPayloadSize(json, 'putClientState'); | ||
| const encrypted = await this._encrypt(json); | ||
|
|
||
| const res = await fetch(`${this._baseUrl}/client-state`, { | ||
| method: 'PUT', | ||
| headers: this._withServerToken({ | ||
| 'Content-Type': 'application/json', | ||
| 'X-User-Id': this._userId, | ||
| 'X-Payload-Encrypted': 'true', | ||
| 'X-Encryption-Version': '1' | ||
| }), | ||
| body: JSON.stringify(encrypted) | ||
| }); | ||
|
|
||
| return res.json(); | ||
| } | ||
|
|
||
| /** | ||
| * Read every entry of one client-state namespace. | ||
| * | ||
| * The response rides the encrypted-response envelope (same as | ||
| * `listMessages`); this method decrypts it and returns plaintext | ||
| * values. | ||
| * | ||
| * @param {string} namespace | ||
| * @returns {Promise<Object>} `{ success, data?: { namespace, entries }, error? }` | ||
| * where `entries` is `Array<{ namespace, key, value, updatedAt }>`. | ||
| */ | ||
| async getClientState(namespace) { | ||
| if (typeof namespace !== 'string' || !namespace.trim()) { | ||
| throw new TypeError('[rei-standard-amsg-client] namespace must be a non-empty string'); | ||
| } | ||
| const res = await fetch( | ||
|
Comment on lines
+1048
to
+1052
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 建议在客户端对 async getClientState(namespace) {
if (typeof namespace !== 'string' || !namespace.trim()) {
throw new TypeError('[rei-standard-amsg-client] namespace must be a non-empty string');
}
const res = await fetch( |
||
| `${this._baseUrl}/client-state?namespace=${encodeURIComponent(namespace)}`, | ||
| { | ||
| method: 'GET', | ||
| headers: this._withServerToken({ | ||
| 'X-User-Id': this._userId, | ||
| 'X-Response-Encrypted': 'true', | ||
| 'X-Encryption-Version': '1' | ||
| }) | ||
| } | ||
| ); | ||
|
|
||
| const json = await res.json(); | ||
| if (!json?.success || json?.encrypted !== true) return json; | ||
|
|
||
| const decrypted = await this._decrypt(json.data); | ||
| return { | ||
| success: true, | ||
| encrypted: true, | ||
| version: json.version || 1, | ||
| data: decrypted | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Wipe every client-state entry of this user, across all namespaces — | ||
| * e.g. behind a "clear cloud state" settings action. | ||
| * | ||
| * @returns {Promise<Object>} `{ success, data?: { deleted }, error? }` | ||
| */ | ||
| async clearClientState() { | ||
| const res = await fetch(`${this._baseUrl}/client-state`, { | ||
| method: 'DELETE', | ||
| headers: this._withServerToken({ 'X-User-Id': this._userId }) | ||
| }); | ||
|
|
||
| return res.json(); | ||
| } | ||
|
|
||
| // ─── Push Subscription ────────────────────────────────────────── | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| import { test } from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
| import { ReiClient } from '../src/index.js'; | ||
|
|
||
| const USER = '550e8400-e29b-41d4-a716-446655440000'; | ||
| const USER_KEY_HEX = 'ab'.repeat(32); // 64 hex chars → 32-byte AES key | ||
|
|
||
| /** Build a client and feed it a userKey via a mocked init() round trip. */ | ||
| async function makeInitializedClient(config = {}) { | ||
| const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER, ...config }); | ||
| const original = globalThis.fetch; | ||
| globalThis.fetch = async () => new Response( | ||
| JSON.stringify({ success: true, data: { userKey: USER_KEY_HEX } }), | ||
| { status: 200, headers: { 'Content-Type': 'application/json' } } | ||
| ); | ||
| try { | ||
| await client.init(); | ||
| } finally { | ||
| globalThis.fetch = original; | ||
| } | ||
| return client; | ||
| } | ||
|
|
||
| test('putClientState() PUTs an encrypted batch and returns the server verdict', async () => { | ||
| const client = await makeInitializedClient({ serverToken: 's3cret' }); | ||
| const entries = [ | ||
| { namespace: 'profile', key: 'display', value: '{"name":"A"}', updatedAt: 1700000000000 }, | ||
| { namespace: 'profile', key: 'locale', value: '"zh-CN"', updatedAt: 1700000000001 } | ||
| ]; | ||
|
|
||
| const captured = []; | ||
| const original = globalThis.fetch; | ||
| globalThis.fetch = async (url, init) => { | ||
| captured.push({ url: String(url), method: init.method, headers: init.headers, body: init.body }); | ||
| return new Response(JSON.stringify({ success: true, data: { upserted: 2, skipped: 0 } }), { | ||
| status: 200, headers: { 'Content-Type': 'application/json' } | ||
| }); | ||
| }; | ||
| let result; | ||
| try { | ||
| result = await client.putClientState(entries); | ||
| } finally { | ||
| globalThis.fetch = original; | ||
| } | ||
|
|
||
| assert.equal(captured.length, 1); | ||
| assert.equal(captured[0].url, 'https://w.dev/client-state'); | ||
| assert.equal(captured[0].method, 'PUT'); | ||
| assert.equal(captured[0].headers['X-User-Id'], USER); | ||
| assert.equal(captured[0].headers['X-Payload-Encrypted'], 'true'); | ||
| assert.equal(captured[0].headers['X-Encryption-Version'], '1'); | ||
| assert.equal(captured[0].headers['X-Client-Token'], 's3cret'); | ||
|
|
||
| // Body is the encrypted envelope, not plaintext — and decrypts back to the batch. | ||
| const body = JSON.parse(captured[0].body); | ||
| assert.equal(typeof body.iv, 'string'); | ||
| assert.equal(typeof body.authTag, 'string'); | ||
| assert.equal(typeof body.encryptedData, 'string'); | ||
| assert.ok(!captured[0].body.includes('zh-CN'), 'plaintext must not appear in the request body'); | ||
| const roundtripped = await client._decrypt(body); | ||
| assert.deepEqual(roundtripped, { entries }); | ||
|
|
||
| assert.deepEqual(result, { success: true, data: { upserted: 2, skipped: 0 } }); | ||
| }); | ||
|
|
||
| test('getClientState() decrypts the encrypted-response envelope', async () => { | ||
| const client = await makeInitializedClient({ serverToken: 's3cret' }); | ||
| const payload = { | ||
| namespace: 'char:42:persona', | ||
| entries: [{ namespace: 'char:42:persona', key: 'card', value: 'hello', updatedAt: 1700000000000 }] | ||
| }; | ||
| const encrypted = await client._encrypt(JSON.stringify(payload)); | ||
|
|
||
| const captured = []; | ||
| const original = globalThis.fetch; | ||
| globalThis.fetch = async (url, init) => { | ||
| captured.push({ url: String(url), method: init.method, headers: init.headers }); | ||
| return new Response( | ||
| JSON.stringify({ success: true, encrypted: true, version: 1, data: encrypted }), | ||
| { status: 200, headers: { 'Content-Type': 'application/json' } } | ||
| ); | ||
| }; | ||
| let result; | ||
| try { | ||
| result = await client.getClientState('char:42:persona'); | ||
| } finally { | ||
| globalThis.fetch = original; | ||
| } | ||
|
|
||
| assert.equal(captured[0].url, `https://w.dev/client-state?namespace=${encodeURIComponent('char:42:persona')}`); | ||
| assert.equal(captured[0].method, 'GET'); | ||
| assert.equal(captured[0].headers['X-User-Id'], USER); | ||
| assert.equal(captured[0].headers['X-Response-Encrypted'], 'true'); | ||
| assert.equal(captured[0].headers['X-Client-Token'], 's3cret'); | ||
| assert.equal(result.success, true); | ||
| assert.deepEqual(result.data, payload); | ||
| }); | ||
|
|
||
| test('getClientState() passes a non-success response through untouched', async () => { | ||
| const client = await makeInitializedClient(); | ||
| const failure = { success: false, error: { code: 'NAMESPACE_REQUIRED', message: '必须提供 namespace 查询参数' } }; | ||
|
|
||
| const original = globalThis.fetch; | ||
| globalThis.fetch = async () => new Response(JSON.stringify(failure), { | ||
| status: 400, headers: { 'Content-Type': 'application/json' } | ||
| }); | ||
| let result; | ||
| try { | ||
| result = await client.getClientState('profile'); | ||
| } finally { | ||
| globalThis.fetch = original; | ||
| } | ||
| assert.deepEqual(result, failure); | ||
| }); | ||
|
|
||
| test('clearClientState() DELETEs /client-state with user + token headers', async () => { | ||
| const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER, serverToken: 's3cret' }); | ||
|
|
||
| const captured = []; | ||
| const original = globalThis.fetch; | ||
| globalThis.fetch = async (url, init) => { | ||
| captured.push({ url: String(url), method: init.method, headers: init.headers }); | ||
| return new Response(JSON.stringify({ success: true, data: { deleted: 5 } }), { | ||
| status: 200, headers: { 'Content-Type': 'application/json' } | ||
| }); | ||
| }; | ||
| let result; | ||
| try { | ||
| result = await client.clearClientState(); | ||
| } finally { | ||
| globalThis.fetch = original; | ||
| } | ||
|
|
||
| assert.equal(captured[0].url, 'https://w.dev/client-state'); | ||
| assert.equal(captured[0].method, 'DELETE'); | ||
| assert.equal(captured[0].headers['X-User-Id'], USER); | ||
| assert.equal(captured[0].headers['X-Client-Token'], 's3cret'); | ||
| assert.deepEqual(result, { success: true, data: { deleted: 5 } }); | ||
| }); | ||
|
|
||
| test('putClientState() before init() throws Not initialised', async () => { | ||
| const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER }); | ||
| await assert.rejects( | ||
| () => client.putClientState([{ namespace: 'a', key: 'b', value: 'c', updatedAt: 1 }]), | ||
| /Not initialised/ | ||
| ); | ||
| }); | ||
|
Comment on lines
+141
to
+147
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 为了配合新增的参数校验,建议在测试文件中补充对应的单元测试,确保非法参数能够正确触发 test('putClientState() before init() throws Not initialised', async () => {
const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER });
await assert.rejects(
() => client.putClientState([{ namespace: 'a', key: 'b', value: 'c', updatedAt: 1 }]),
/Not initialised/
);
});
test('putClientState() and getClientState() validate arguments', async () => {
const client = await makeInitializedClient();
await assert.rejects(
() => client.putClientState(),
/entries must be a non-empty array/
);
await assert.rejects(
() => client.putClientState([]),
/entries must be a non-empty array/
);
await assert.rejects(
() => client.getClientState(''),
/namespace must be a non-empty string/
);
}); |
||
|
|
||
| test('putClientState() / getClientState() reject invalid arguments before any network call', async () => { | ||
| const client = await makeInitializedClient(); | ||
| const original = globalThis.fetch; | ||
| globalThis.fetch = async () => { throw new Error('must not reach the network'); }; | ||
| try { | ||
| await assert.rejects(() => client.putClientState(), /entries must be a non-empty array/); | ||
| await assert.rejects(() => client.putClientState([]), /entries must be a non-empty array/); | ||
| await assert.rejects(() => client.putClientState('nope'), /entries must be a non-empty array/); | ||
| await assert.rejects(() => client.getClientState(), /namespace must be a non-empty string/); | ||
| await assert.rejects(() => client.getClientState(' '), /namespace must be a non-empty string/); | ||
| } finally { | ||
| globalThis.fetch = original; | ||
| } | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
在进行高开销的加密操作(
_encrypt)和网络请求之前,建议在客户端对entries参数进行前置校验。如果entries未定义、不是数组或为空数组,JSON.stringify({ entries })会产生非预期的 JSON(例如entries为undefined时会变成"{}"),且会白白消耗加密算力并发送注定失败的请求。