Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/amsg-client-client-state.md
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`。
98 changes: 98 additions & 0 deletions packages/rei-standard-amsg/client/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Comment on lines +1015 to +1019

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在进行高开销的加密操作(_encrypt)和网络请求之前,建议在客户端对 entries 参数进行前置校验。如果 entries 未定义、不是数组或为空数组,JSON.stringify({ entries }) 会产生非预期的 JSON(例如 entriesundefined 时会变成 "{}"),且会白白消耗加密算力并发送注定失败的请求。

Suggested change
async putClientState(entries) {
const json = JSON.stringify({ entries });
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

建议在客户端对 namespace 参数进行前置校验。如果 namespace 传入了非字符串或空字符串,encodeURIComponent(namespace) 会将其转换为 "undefined" 或空值,从而发送无效的请求。在客户端直接抛出 TypeError 可以避免不必要的网络开销。

  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 ──────────────────────────────────────────

/**
Expand Down
162 changes: 162 additions & 0 deletions packages/rei-standard-amsg/client/test/client-state.test.mjs
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

为了配合新增的参数校验,建议在测试文件中补充对应的单元测试,确保非法参数能够正确触发 TypeError

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;
}
});
Loading