@hadoku/prefs-client 1.1.9 → 1.1.11
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 +8 -1
- package/dist/avatar.d.ts +16 -5
- package/dist/items.d.ts +37 -0
- package/dist/items.js +76 -0
- package/dist/service.d.ts +14 -0
- package/dist/service.js +66 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -147,9 +147,16 @@ const byUser = await resolveAvatars(
|
|
|
147
147
|
{ serviceKey: process.env.SERVICE_KEY! },
|
|
148
148
|
userIds
|
|
149
149
|
);
|
|
150
|
-
// every requested id is present; unknown users resolve to { source: 'sprite' }
|
|
150
|
+
// every requested id is present; unknown users resolve to { source: 'sprite', name: null }
|
|
151
|
+
byUser[id].name; // their live display name, or null for an unknown or closed account
|
|
151
152
|
```
|
|
152
153
|
|
|
154
|
+
**Names come from here too.** A server stores only userIds (IDENTITY_MODEL R12) and names
|
|
155
|
+
people, online or not, when it shows them: `resolveAvatars(...)[id].name`. Never keep a copy.
|
|
156
|
+
prefs-api reads the registry at most once a minute per isolate, so a rename shows within a
|
|
157
|
+
minute and a deleted account stops being named. Over HTTP it is `POST /prefs/api/v1/_avatar/resolve`
|
|
158
|
+
`{ userIds }` with a service-tier `X-User-Key`.
|
|
159
|
+
|
|
153
160
|
Rules for an app that renders avatars:
|
|
154
161
|
|
|
155
162
|
- **`url` → `<img src>`**, nothing else: never fetch it server-side, never proxy it. Set
|
package/dist/avatar.d.ts
CHANGED
|
@@ -93,9 +93,18 @@ interface LinkStart {
|
|
|
93
93
|
interface ResolveRequest {
|
|
94
94
|
userIds: string[];
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* One person as a server shows them: their avatar and their live display name.
|
|
98
|
+
* `name` is null for an unknown id or a closed (deleted, banned) account, so a
|
|
99
|
+
* server that stores only userIds (IDENTITY_MODEL R12) names people at display
|
|
100
|
+
* time and never keeps a copy.
|
|
101
|
+
*/
|
|
102
|
+
interface ResolvedPerson extends ResolvedAvatar {
|
|
103
|
+
name: string | null;
|
|
104
|
+
}
|
|
96
105
|
interface ResolveResponse {
|
|
97
|
-
/** Every requested id is present; an unknown user resolves to `{ source: 'sprite' }`. */
|
|
98
|
-
avatars: Record<string,
|
|
106
|
+
/** Every requested id is present; an unknown user resolves to `{ source: 'sprite', name: null }`. */
|
|
107
|
+
avatars: Record<string, ResolvedPerson>;
|
|
99
108
|
}
|
|
100
109
|
declare const RESOLVE_MAX_IDS = 100;
|
|
101
110
|
/** A watchparty character id, as prefs-api accepts one from the watchparty blob. */
|
|
@@ -156,9 +165,11 @@ interface ResolveAvatarsOptions extends AvatarClientOptions {
|
|
|
156
165
|
serviceKey: string;
|
|
157
166
|
}
|
|
158
167
|
/**
|
|
159
|
-
* Server-side: resolve many users
|
|
168
|
+
* Server-side: resolve many users by userId: each one's avatar and live display
|
|
169
|
+
* `name` (null for an unknown or closed account). Store only the userId and
|
|
170
|
+
* call this when showing people (IDENTITY_MODEL R12). Batches past
|
|
160
171
|
* RESOLVE_MAX_IDS for you. Every requested id is in the result.
|
|
161
172
|
*/
|
|
162
|
-
declare function resolveAvatars(opts: ResolveAvatarsOptions, userIds: readonly string[]): Promise<Record<string,
|
|
173
|
+
declare function resolveAvatars(opts: ResolveAvatarsOptions, userIds: readonly string[]): Promise<Record<string, ResolvedPerson>>;
|
|
163
174
|
|
|
164
|
-
export { AVATAR_SOURCES, type AvatarClient, type AvatarClientOptions, AvatarError, type AvatarSettings, type AvatarSource, type AvatarUpdate, type AvatarUploadLimits, LINK_CALLBACK_PATH, LINK_MESSAGE_TYPE, LINK_POPUP_MARK, LINK_PROVIDERS, type LinkMessage, type LinkProvider, type LinkStart, type LinkedAccountView, RESOLVE_MAX_IDS, type ResolveAvatarsOptions, type ResolveRequest, type ResolveResponse, type ResolvedAvatar, SPRITE_ID_PATTERN, type UploadView, createAvatarClient, resolveAvatars };
|
|
175
|
+
export { AVATAR_SOURCES, type AvatarClient, type AvatarClientOptions, AvatarError, type AvatarSettings, type AvatarSource, type AvatarUpdate, type AvatarUploadLimits, LINK_CALLBACK_PATH, LINK_MESSAGE_TYPE, LINK_POPUP_MARK, LINK_PROVIDERS, type LinkMessage, type LinkProvider, type LinkStart, type LinkedAccountView, RESOLVE_MAX_IDS, type ResolveAvatarsOptions, type ResolveRequest, type ResolveResponse, type ResolvedAvatar, type ResolvedPerson, SPRITE_ID_PATTERN, type UploadView, createAvatarClient, resolveAvatars };
|
package/dist/items.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
interface Item<T = unknown> {
|
|
2
|
+
key: string;
|
|
3
|
+
value: T;
|
|
4
|
+
version: number;
|
|
5
|
+
updatedAt: number;
|
|
6
|
+
}
|
|
7
|
+
type ItemWriteResult<T = unknown> = {
|
|
8
|
+
ok: true;
|
|
9
|
+
item: Item<T>;
|
|
10
|
+
}
|
|
11
|
+
/** Someone wrote first (or `expectedVersion: 0` and it exists): `current` is what is there. */
|
|
12
|
+
| {
|
|
13
|
+
ok: false;
|
|
14
|
+
conflict: Item<T> | null;
|
|
15
|
+
};
|
|
16
|
+
declare class ItemsError extends Error {
|
|
17
|
+
readonly status: number;
|
|
18
|
+
constructor(status: number, message: string);
|
|
19
|
+
}
|
|
20
|
+
interface ItemsClientOptions {
|
|
21
|
+
appId: string;
|
|
22
|
+
/** The person's key, as the edge forwarded it. Omit in a browser (cookie). */
|
|
23
|
+
userKey?: string;
|
|
24
|
+
/** Default https://hadoku.me/prefs. */
|
|
25
|
+
baseUrl?: string;
|
|
26
|
+
fetch?: typeof fetch;
|
|
27
|
+
}
|
|
28
|
+
declare function createItemsClient(opts: ItemsClientOptions): {
|
|
29
|
+
get<T = unknown>(key: string): Promise<Item<T> | null>;
|
|
30
|
+
/** Every item under `prefix`, following pages. */
|
|
31
|
+
list<T = unknown>(prefix?: string): Promise<Item<T>[]>;
|
|
32
|
+
/** Replace one item. `expectedVersion` refuses a lost update; 0 means "only if absent". */
|
|
33
|
+
put<T = unknown>(key: string, value: T, expectedVersion?: number): Promise<ItemWriteResult<T>>;
|
|
34
|
+
delete(key: string): Promise<boolean>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export { type Item, type ItemWriteResult, type ItemsClientOptions, ItemsError, createItemsClient };
|
package/dist/items.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// src/constants.ts
|
|
2
|
+
var PREFS_API_BASE = "https://hadoku.me/prefs";
|
|
3
|
+
|
|
4
|
+
// src/items.ts
|
|
5
|
+
var ItemsError = class extends Error {
|
|
6
|
+
constructor(status, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.name = "ItemsError";
|
|
10
|
+
}
|
|
11
|
+
status;
|
|
12
|
+
};
|
|
13
|
+
var APP = /^[a-z0-9-]+$/;
|
|
14
|
+
function createItemsClient(opts) {
|
|
15
|
+
if (!APP.test(opts.appId)) throw new Error(`invalid appId: ${opts.appId}`);
|
|
16
|
+
const f = opts.fetch ?? fetch;
|
|
17
|
+
const base = `${opts.baseUrl ?? PREFS_API_BASE}/api/v1/${opts.appId}/items`;
|
|
18
|
+
const headers = {
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
...opts.userKey ? { "X-User-Key": opts.userKey } : {}
|
|
21
|
+
};
|
|
22
|
+
const url = (key) => `${base}/${encodeURIComponent(key)}`;
|
|
23
|
+
return {
|
|
24
|
+
async get(key) {
|
|
25
|
+
const res = await f(url(key), { headers, credentials: "same-origin" });
|
|
26
|
+
if (res.status === 404) return null;
|
|
27
|
+
if (!res.ok) throw await failure(res);
|
|
28
|
+
return await res.json();
|
|
29
|
+
},
|
|
30
|
+
/** Every item under `prefix`, following pages. */
|
|
31
|
+
async list(prefix = "") {
|
|
32
|
+
const out = [];
|
|
33
|
+
let after = null;
|
|
34
|
+
do {
|
|
35
|
+
const q = new URLSearchParams({ prefix, limit: "500", ...after ? { after } : {} });
|
|
36
|
+
const res = await f(`${base}?${q}`, { headers, credentials: "same-origin" });
|
|
37
|
+
if (!res.ok) throw await failure(res);
|
|
38
|
+
const page = await res.json();
|
|
39
|
+
out.push(...page.items);
|
|
40
|
+
after = page.next;
|
|
41
|
+
} while (after);
|
|
42
|
+
return out;
|
|
43
|
+
},
|
|
44
|
+
/** Replace one item. `expectedVersion` refuses a lost update; 0 means "only if absent". */
|
|
45
|
+
async put(key, value, expectedVersion) {
|
|
46
|
+
const res = await f(url(key), {
|
|
47
|
+
method: "PUT",
|
|
48
|
+
headers,
|
|
49
|
+
credentials: "same-origin",
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
value,
|
|
52
|
+
...expectedVersion !== void 0 ? { expectedVersion } : {}
|
|
53
|
+
})
|
|
54
|
+
});
|
|
55
|
+
if (res.status === 409) {
|
|
56
|
+
const body = await res.json().catch(() => null);
|
|
57
|
+
return { ok: false, conflict: body?.current ?? null };
|
|
58
|
+
}
|
|
59
|
+
if (!res.ok) throw await failure(res);
|
|
60
|
+
return { ok: true, item: await res.json() };
|
|
61
|
+
},
|
|
62
|
+
async delete(key) {
|
|
63
|
+
const res = await f(url(key), { method: "DELETE", headers, credentials: "same-origin" });
|
|
64
|
+
if (!res.ok) throw await failure(res);
|
|
65
|
+
return (await res.json()).deleted;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
async function failure(res) {
|
|
70
|
+
const body = await res.json().catch(() => null);
|
|
71
|
+
return new ItemsError(res.status, body?.message ?? body?.error ?? `HTTP ${res.status}`);
|
|
72
|
+
}
|
|
73
|
+
export {
|
|
74
|
+
ItemsError,
|
|
75
|
+
createItemsClient
|
|
76
|
+
};
|
package/dist/service.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Item, ItemWriteResult } from './items.js';
|
|
2
|
+
|
|
1
3
|
interface ServicePrefsOptions {
|
|
2
4
|
/** The app's namespace: `cardtable` reaches `cardtable` and `cardtable-*`. */
|
|
3
5
|
app: string;
|
|
@@ -39,6 +41,18 @@ declare function createServicePrefsClient(opts: ServicePrefsOptions): {
|
|
|
39
41
|
read<T = Record<string, unknown>>(appId: string, userIds: readonly string[]): Promise<ServiceRow<T>[]>;
|
|
40
42
|
/** Every person's row in one app, page by page. */
|
|
41
43
|
all<T = Record<string, unknown>>(appId: string, pageSize?: number): AsyncGenerator<ServiceRow<T>>;
|
|
44
|
+
/** Items across people (these userIds, ≤90 a call, or everyone), under an optional prefix. */
|
|
45
|
+
readItems<T = unknown>(appId: string, opts?: {
|
|
46
|
+
userIds?: readonly string[];
|
|
47
|
+
prefix?: string;
|
|
48
|
+
}): Promise<(Item<T> & {
|
|
49
|
+
userId: string;
|
|
50
|
+
})[]>;
|
|
51
|
+
/** Write one of a person's items, the person named by their own live key. */
|
|
52
|
+
writeItemFor<T = unknown>(appId: string, userKey: string, key: string, value: T, expectedVersion?: number): Promise<ItemWriteResult<T>>;
|
|
53
|
+
deleteItemFor(appId: string, userKey: string, key: string): Promise<boolean>;
|
|
54
|
+
/** Delete EVERY person's items under a non-empty prefix (a deleted set, say). */
|
|
55
|
+
deleteItemsAcross(appId: string, prefix: string): Promise<number>;
|
|
42
56
|
/**
|
|
43
57
|
* Shallow-merge `patch` into the person's row, the person named by their
|
|
44
58
|
* own live key. Pass the `expectedVersion` you read to refuse a lost update.
|
package/dist/service.js
CHANGED
|
@@ -23,6 +23,11 @@ function createServicePrefsClient(opts) {
|
|
|
23
23
|
}
|
|
24
24
|
return `${base}/${appId}`;
|
|
25
25
|
};
|
|
26
|
+
const itemsBase = `${opts.baseUrl ?? PREFS_API_BASE}/api/v1/_service/${opts.app}/items`;
|
|
27
|
+
const itemsUrl = (appId) => {
|
|
28
|
+
rowsUrl(appId);
|
|
29
|
+
return `${itemsBase}/${appId}`;
|
|
30
|
+
};
|
|
26
31
|
async function page(appId, body) {
|
|
27
32
|
const res = await f(`${rowsUrl(appId)}/read`, {
|
|
28
33
|
method: "POST",
|
|
@@ -55,6 +60,67 @@ function createServicePrefsClient(opts) {
|
|
|
55
60
|
after = p.next;
|
|
56
61
|
} while (after);
|
|
57
62
|
},
|
|
63
|
+
/** Items across people (these userIds, ≤90 a call, or everyone), under an optional prefix. */
|
|
64
|
+
async readItems(appId, opts2 = {}) {
|
|
65
|
+
const out = [];
|
|
66
|
+
const batches = opts2.userIds ? Array.from(
|
|
67
|
+
{ length: Math.ceil(opts2.userIds.length / SERVICE_READ_BATCH) },
|
|
68
|
+
(_, i) => opts2.userIds.slice(i * SERVICE_READ_BATCH, (i + 1) * SERVICE_READ_BATCH)
|
|
69
|
+
) : [void 0];
|
|
70
|
+
for (const userIds of batches) {
|
|
71
|
+
if (userIds && userIds.length === 0) continue;
|
|
72
|
+
let after = null;
|
|
73
|
+
do {
|
|
74
|
+
const res = await f(`${itemsUrl(appId)}/read`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers,
|
|
77
|
+
body: JSON.stringify({ userIds, prefix: opts2.prefix, after, limit: 500 })
|
|
78
|
+
});
|
|
79
|
+
if (!res.ok) throw await failure(res);
|
|
80
|
+
const page2 = await res.json();
|
|
81
|
+
out.push(...page2.items);
|
|
82
|
+
after = page2.next;
|
|
83
|
+
} while (after);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
},
|
|
87
|
+
/** Write one of a person's items, the person named by their own live key. */
|
|
88
|
+
async writeItemFor(appId, userKey, key, value, expectedVersion) {
|
|
89
|
+
const res = await f(`${itemsUrl(appId)}/${encodeURIComponent(key)}`, {
|
|
90
|
+
method: "PUT",
|
|
91
|
+
headers,
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
userKey,
|
|
94
|
+
value,
|
|
95
|
+
...expectedVersion !== void 0 ? { expectedVersion } : {}
|
|
96
|
+
})
|
|
97
|
+
});
|
|
98
|
+
if (res.status === 409) {
|
|
99
|
+
const body = await res.json().catch(() => null);
|
|
100
|
+
return { ok: false, conflict: body?.current ?? null };
|
|
101
|
+
}
|
|
102
|
+
if (!res.ok) throw await failure(res);
|
|
103
|
+
return { ok: true, item: await res.json() };
|
|
104
|
+
},
|
|
105
|
+
async deleteItemFor(appId, userKey, key) {
|
|
106
|
+
const res = await f(`${itemsUrl(appId)}/${encodeURIComponent(key)}`, {
|
|
107
|
+
method: "DELETE",
|
|
108
|
+
headers,
|
|
109
|
+
body: JSON.stringify({ userKey })
|
|
110
|
+
});
|
|
111
|
+
if (!res.ok) throw await failure(res);
|
|
112
|
+
return (await res.json()).deleted;
|
|
113
|
+
},
|
|
114
|
+
/** Delete EVERY person's items under a non-empty prefix (a deleted set, say). */
|
|
115
|
+
async deleteItemsAcross(appId, prefix) {
|
|
116
|
+
const res = await f(`${itemsUrl(appId)}/delete-across`, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers,
|
|
119
|
+
body: JSON.stringify({ prefix })
|
|
120
|
+
});
|
|
121
|
+
if (!res.ok) throw await failure(res);
|
|
122
|
+
return (await res.json()).deleted;
|
|
123
|
+
},
|
|
58
124
|
/**
|
|
59
125
|
* Shallow-merge `patch` into the person's row, the person named by their
|
|
60
126
|
* own live key. Pass the `expectedVersion` you read to refuse a lost update.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hadoku/prefs-client",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.11",
|
|
4
4
|
"description": "Client SDK for the hadoku unified preferences store. Wraps /prefs/api/v1/* with localStorage caching, debounced writes, Zod-validated reads, and a usePrefs() React hook, plus match history, ratings and leaderboards for every game (./games), and the one resolved avatar per user (./avatar).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
"./service": {
|
|
30
30
|
"types": "./dist/service.d.ts",
|
|
31
31
|
"default": "./dist/service.js"
|
|
32
|
+
},
|
|
33
|
+
"./items": {
|
|
34
|
+
"types": "./dist/items.d.ts",
|
|
35
|
+
"default": "./dist/items.js"
|
|
32
36
|
}
|
|
33
37
|
},
|
|
34
38
|
"files": [
|