enka.ts 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/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # enka.ts
2
+
3
+ Typed Genshin Impact enka.network API wrapper that works
4
+
5
+ ## Features
6
+
7
+ - Built-in TTL cache using Enka's response `ttl`
8
+ - Client-side rate limiter (default: 50 requests / minute)
9
+ - Automatic avatar resolution using https://gi.yatta.moe/api/v2/en/avatar
10
+ - Fully typed responses
11
+ - Typed error handling
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ $ bun i enka.ts
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```ts
22
+ import { ApiError, fetchEnka } from "enka.ts";
23
+
24
+ try {
25
+ const player = await fetchEnka("800123456");
26
+
27
+ // Or use ?info endpoint to exclude build details for faster query
28
+ const playerInfo = await fetchEnka("800123456", "info");
29
+
30
+ console.log(player.playerInfo.nickname);
31
+ } catch(err) {
32
+ if(err instanceof ApiError) {
33
+ if(err.type === "uid_format") // handle invalid uid format
34
+ else if(err.type === "not_found") // handle user not found
35
+ else if(err.type === "maintenance") // handle maintenance
36
+ else if(err.type === "internal") // handle internal server error
37
+ else if(err.type === "ratelimit") // handle rate limit exceeded
38
+ }
39
+ }
40
+ ```
41
+
42
+ > [!NOTE]
43
+ > Locations that normally contain `avatarId` will also include a resolved
44
+ > `avatar` object:
45
+ > ```ts
46
+ > {
47
+ > avatarId: number;
48
+ > name: string;
49
+ > element: AvatarElement;
50
+ > }
@@ -0,0 +1,145 @@
1
+ declare class TTLCache {
2
+ private cache;
3
+ get<T>(key: string): T | undefined;
4
+ clean(): void;
5
+ set<T>(key: string, value: T, ttlSeconds: number): void;
6
+ }
7
+
8
+ declare namespace ApiResponse {
9
+ interface Root {
10
+ uid: string;
11
+ ttl: number;
12
+ playerInfo: PlayerInfo;
13
+ avatarInfoList?: AvatarInfo[];
14
+ }
15
+ interface PlayerInfo {
16
+ nickname: string;
17
+ signature: string;
18
+ level: number;
19
+ worldLevel: number;
20
+ namecardId: number;
21
+ finishAchievementNum: number;
22
+ towerFloorIndex: number;
23
+ towerLevelIndex: number;
24
+ showAvatarInfoList: ShowAvatarInfo[];
25
+ showNameCardIdList: number[];
26
+ profilePicture: {
27
+ avatarId?: number;
28
+ id?: number;
29
+ costumeId?: number;
30
+ };
31
+ }
32
+ interface ShowAvatarInfo {
33
+ avatarId: number;
34
+ level: number;
35
+ costumeId?: number;
36
+ }
37
+ interface AvatarInfo {
38
+ avatarId: number;
39
+ propMap: Record<string, PropValue>;
40
+ fightPropMap: Record<string, number>;
41
+ skillDepotId: number;
42
+ inherentProudSkillList?: number[];
43
+ talentIdList?: number[];
44
+ skillLevelMap?: Record<string, number>;
45
+ equipList?: Equip[];
46
+ fetterInfo?: {
47
+ expLevel: number;
48
+ };
49
+ }
50
+ interface PropValue {
51
+ type: number;
52
+ ival?: string;
53
+ val?: number;
54
+ }
55
+ interface Equip {
56
+ itemId: number;
57
+ reliquary?: ArtifactData;
58
+ weapon?: WeaponData;
59
+ flat: FlatData;
60
+ }
61
+ interface WeaponData {
62
+ level: number;
63
+ promoteLevel: number;
64
+ affixMap?: Record<string, number>;
65
+ }
66
+ interface ArtifactData {
67
+ level: number;
68
+ mainPropId: number;
69
+ appendPropIdList?: number[];
70
+ }
71
+ interface FlatData {
72
+ nameTextMapHash: number;
73
+ setNameTextMapHash?: number;
74
+ icon: string;
75
+ rankLevel?: number;
76
+ reliquaryMainstat?: {
77
+ mainPropId: string;
78
+ statValue: number;
79
+ };
80
+ reliquarySubstats?: {
81
+ appendPropId: string;
82
+ statValue: number;
83
+ }[];
84
+ weaponStats?: {
85
+ appendPropId: string;
86
+ statValue: number;
87
+ }[];
88
+ }
89
+ }
90
+ declare enum AvatarElement {
91
+ Pyro = "Fire",
92
+ Hydro = "Water",
93
+ Electro = "Electric",
94
+ Cryo = "Ice",
95
+ Dendro = "Grass",
96
+ Anemo = "Wind",
97
+ Geo = "Rock"
98
+ }
99
+ interface BaseAvatar {
100
+ avatarId: number;
101
+ name: string;
102
+ element: AvatarElement;
103
+ }
104
+ type ResolveAvatar<T extends {
105
+ avatarId: number;
106
+ }> = Omit<T, "avatarId"> & {
107
+ avatar: BaseAvatar;
108
+ };
109
+ type ResolvedProfilePicture = Omit<ApiResponse.PlayerInfo["profilePicture"], "avatarId"> & {
110
+ avatar?: BaseAvatar;
111
+ };
112
+ type ResolvedPlayerInfo = Omit<ApiResponse.PlayerInfo, "showAvatarInfoList" | "profilePicture"> & {
113
+ profilePicture: ResolvedProfilePicture;
114
+ showAvatarInfoList: ResolveAvatar<ApiResponse.ShowAvatarInfo>[];
115
+ };
116
+ type ResolvedAvatarInfo = ResolveAvatar<ApiResponse.AvatarInfo>;
117
+ type ResolvedRoot = Omit<ApiResponse.Root, "playerInfo" | "avatarInfoList"> & {
118
+ playerInfo: ResolvedPlayerInfo;
119
+ avatarInfoList?: ResolvedAvatarInfo[];
120
+ };
121
+
122
+ declare const uidRegex: RegExp;
123
+ type Mode = "full" | "info";
124
+ declare function fetchEnka(uid: string, mode?: Mode): Promise<ResolvedRoot>;
125
+
126
+ type ApiErrorType = "uid_format" | "not_found" | "maintenance" | "internal" | "ratelimit";
127
+ declare class ApiError extends Error {
128
+ type: ApiErrorType;
129
+ constructor(type: ApiErrorType, message?: string);
130
+ }
131
+
132
+ declare class RateLimiter {
133
+ private limit;
134
+ private mode;
135
+ private active;
136
+ private queue;
137
+ constructor(limit: number, interval: number, mode?: "wait" | "throw");
138
+ private flush;
139
+ fill(): void;
140
+ acquire(): Promise<void>;
141
+ }
142
+
143
+ declare function resolveRoot(data: ApiResponse.Root): Promise<ResolvedRoot>;
144
+
145
+ export { ApiError, ApiResponse, AvatarElement, type BaseAvatar, type Mode, RateLimiter, type ResolveAvatar, type ResolvedAvatarInfo, type ResolvedPlayerInfo, type ResolvedProfilePicture, type ResolvedRoot, TTLCache, fetchEnka, resolveRoot, uidRegex };
package/dist/index.js ADDED
@@ -0,0 +1,206 @@
1
+ // src/cache.ts
2
+ var TTLCache = class {
3
+ // biome-ignore lint/suspicious/noExplicitAny: cache mapping
4
+ cache = /* @__PURE__ */ new Map();
5
+ get(key) {
6
+ const entry = this.cache.get(key);
7
+ if (!entry) return;
8
+ if (Date.now() > entry.expires) {
9
+ this.cache.delete(key);
10
+ return;
11
+ }
12
+ queueMicrotask(this.clean.bind(this));
13
+ return entry.value;
14
+ }
15
+ clean() {
16
+ for (const [key, entry] of this.cache) {
17
+ if (Date.now() > entry.expires) {
18
+ this.cache.delete(key);
19
+ return;
20
+ }
21
+ }
22
+ }
23
+ set(key, value, ttlSeconds) {
24
+ this.cache.set(key, {
25
+ value,
26
+ expires: Date.now() + ttlSeconds * 1e3
27
+ });
28
+ }
29
+ };
30
+
31
+ // src/errors.ts
32
+ var ApiError = class extends Error {
33
+ type;
34
+ constructor(type, message) {
35
+ if (message) super(message);
36
+ else
37
+ switch (type) {
38
+ case "uid_format":
39
+ super("Invalid UID format");
40
+ break;
41
+ case "not_found":
42
+ super("User not found");
43
+ break;
44
+ case "maintenance":
45
+ super("Enka is under maintenance");
46
+ break;
47
+ case "internal":
48
+ super("Internal server error");
49
+ break;
50
+ case "ratelimit":
51
+ super("Rate limit exceeded");
52
+ break;
53
+ default:
54
+ super("Unknown Error");
55
+ }
56
+ this.type = type;
57
+ }
58
+ };
59
+
60
+ // src/ratelimit.ts
61
+ var RateLimiter = class {
62
+ constructor(limit, interval, mode = "wait") {
63
+ this.limit = limit;
64
+ this.mode = mode;
65
+ setInterval(() => {
66
+ this.active = 0;
67
+ this.flush();
68
+ }, interval);
69
+ }
70
+ active = 0;
71
+ queue = [];
72
+ flush() {
73
+ while (this.queue.length && this.active < this.limit) {
74
+ this.active++;
75
+ const next = this.queue.shift();
76
+ next();
77
+ }
78
+ }
79
+ fill() {
80
+ this.active = this.limit;
81
+ }
82
+ async acquire() {
83
+ if (this.active < this.limit) {
84
+ this.active++;
85
+ return;
86
+ }
87
+ if (this.mode === "throw") {
88
+ throw new ApiError("ratelimit", "Rate limit exceeded (Package side)");
89
+ }
90
+ await new Promise((resolve) => {
91
+ this.queue.push(resolve);
92
+ });
93
+ }
94
+ };
95
+
96
+ // src/types.ts
97
+ var AvatarElement = /* @__PURE__ */ ((AvatarElement3) => {
98
+ AvatarElement3["Pyro"] = "Fire";
99
+ AvatarElement3["Hydro"] = "Water";
100
+ AvatarElement3["Electro"] = "Electric";
101
+ AvatarElement3["Cryo"] = "Ice";
102
+ AvatarElement3["Dendro"] = "Grass";
103
+ AvatarElement3["Anemo"] = "Wind";
104
+ AvatarElement3["Geo"] = "Rock";
105
+ return AvatarElement3;
106
+ })(AvatarElement || {});
107
+
108
+ // src/assets.ts
109
+ var avatarMap = null;
110
+ async function loadAvatars() {
111
+ if (avatarMap) return avatarMap;
112
+ const res = await fetch("https://gi.yatta.moe/api/v2/en/avatar");
113
+ const { data } = await res.json();
114
+ avatarMap = new Map(
115
+ Object.values(data.items).map((c) => [
116
+ c.id,
117
+ {
118
+ avatarId: c.id,
119
+ name: c.name,
120
+ element: c.element
121
+ }
122
+ ])
123
+ );
124
+ return avatarMap;
125
+ }
126
+ async function resolveAvatarId(id) {
127
+ const map = await loadAvatars();
128
+ const avatar = map.get(id);
129
+ if (!avatar) throw new Error(`Unknown avatar ${id}`);
130
+ return avatar;
131
+ }
132
+
133
+ // src/resolve.ts
134
+ async function resolveRoot(data) {
135
+ const playerInfo = {
136
+ ...data.playerInfo,
137
+ profilePicture: {
138
+ ...data.playerInfo.profilePicture,
139
+ avatar: data.playerInfo.profilePicture.avatarId ? await resolveAvatarId(data.playerInfo.profilePicture.avatarId) : void 0
140
+ },
141
+ showAvatarInfoList: await Promise.all(
142
+ data.playerInfo.showAvatarInfoList.map(async (a) => ({
143
+ ...a,
144
+ avatar: await resolveAvatarId(a.avatarId)
145
+ }))
146
+ )
147
+ };
148
+ let avatarInfoList;
149
+ if (data.avatarInfoList) {
150
+ avatarInfoList = await Promise.all(
151
+ data.avatarInfoList.map(async (a) => ({
152
+ ...a,
153
+ avatar: await resolveAvatarId(a.avatarId)
154
+ }))
155
+ );
156
+ }
157
+ return {
158
+ ...data,
159
+ playerInfo,
160
+ avatarInfoList
161
+ };
162
+ }
163
+
164
+ // src/enka.ts
165
+ var uidRegex = /^(?:[0-35-9]|18)[0-9]{8}$/;
166
+ var cache = new TTLCache();
167
+ var limiter = new RateLimiter(50, 6e4, "wait");
168
+ async function fetchEnka(uid, mode = "full") {
169
+ if (!uidRegex.test(uid)) throw new ApiError("uid_format");
170
+ const key = `enka:${uid}:${mode}`;
171
+ const cached = cache.get(key);
172
+ if (cached) return cached;
173
+ await limiter.acquire();
174
+ const url = mode === "info" ? `https://enka.network/api/uid/${uid}?info` : `https://enka.network/api/uid/${uid}`;
175
+ const res = await fetch(url);
176
+ if (!res.ok) {
177
+ switch (res.status) {
178
+ case 400:
179
+ throw new ApiError("uid_format");
180
+ case 404:
181
+ throw new ApiError("not_found");
182
+ case 424:
183
+ throw new ApiError("maintenance");
184
+ case 429:
185
+ throw new ApiError("ratelimit", "Rate limit exceeded (server-side)");
186
+ case 500:
187
+ case 503:
188
+ throw new ApiError("internal");
189
+ default:
190
+ throw new Error(`Enka request failed (${res.status})`);
191
+ }
192
+ }
193
+ const data = await res.json();
194
+ const resolved = await resolveRoot(data);
195
+ cache.set(key, resolved, data.ttl);
196
+ return resolved;
197
+ }
198
+ export {
199
+ ApiError,
200
+ AvatarElement,
201
+ RateLimiter,
202
+ TTLCache,
203
+ fetchEnka,
204
+ resolveRoot,
205
+ uidRegex
206
+ };
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "enka.ts",
3
+ "description": "Typed Genshin Impact enka.network API wrapper that works",
4
+ "type": "module",
5
+ "version": "1.0.0",
6
+ "devDependencies": {
7
+ "@biomejs/biome": "^2.4.6",
8
+ "@types/bun": "latest",
9
+ "tsup": "^8.5.1"
10
+ },
11
+ "peerDependencies": {
12
+ "typescript": "^5"
13
+ },
14
+ "main": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": "./dist/index.js"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsup src/index.ts --format esm --dts",
24
+ "test": "bun test"
25
+ },
26
+ "keywords": [
27
+ "genshin",
28
+ "enka",
29
+ "genshin-impact",
30
+ "enka-api"
31
+ ],
32
+ "license": "MIT"
33
+ }