@nekosuneprojects/warframe-api 1.0.0 → 1.0.1

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.
Files changed (143) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +298 -298
  3. package/data/characters/amesha.json +226 -226
  4. package/data/characters/ash.json +1354 -1354
  5. package/data/characters/ashprime.json +1994 -1994
  6. package/data/characters/atlas.json +916 -916
  7. package/data/characters/atlasprime.json +1632 -1632
  8. package/data/characters/banshee.json +1036 -1036
  9. package/data/characters/bansheeprime.json +1750 -1750
  10. package/data/characters/baruuk.json +555 -555
  11. package/data/characters/baruukprime.json +1298 -1298
  12. package/data/characters/bonewidow.json +354 -354
  13. package/data/characters/caliban.json +434 -434
  14. package/data/characters/calibanprime.json +683 -683
  15. package/data/characters/chroma.json +1344 -1344
  16. package/data/characters/chromaprime.json +1977 -1977
  17. package/data/characters/citrine.json +220 -220
  18. package/data/characters/citrineprime.json +62 -62
  19. package/data/characters/cyte09.json +218 -218
  20. package/data/characters/dagath.json +194 -194
  21. package/data/characters/dante.json +223 -223
  22. package/data/characters/elytron.json +300 -300
  23. package/data/characters/ember.json +1209 -1209
  24. package/data/characters/emberprime.json +1678 -1678
  25. package/data/characters/equinox.json +958 -958
  26. package/data/characters/equinoxprime.json +1644 -1644
  27. package/data/characters/excalibur.json +1603 -1603
  28. package/data/characters/excaliburprime.json +1524 -1524
  29. package/data/characters/excaliburumbra.json +397 -397
  30. package/data/characters/follie.json +246 -246
  31. package/data/characters/frost.json +1330 -1330
  32. package/data/characters/frostprime.json +1686 -1686
  33. package/data/characters/gara.json +925 -925
  34. package/data/characters/garaprime.json +1494 -1494
  35. package/data/characters/garuda.json +851 -851
  36. package/data/characters/garudaprime.json +1449 -1449
  37. package/data/characters/gauss.json +506 -506
  38. package/data/characters/gaussprime.json +1077 -1077
  39. package/data/characters/grendel.json +463 -463
  40. package/data/characters/grendelprime.json +772 -772
  41. package/data/characters/gyre.json +381 -381
  42. package/data/characters/gyreprime.json +403 -403
  43. package/data/characters/harrow.json +916 -916
  44. package/data/characters/harrowprime.json +1700 -1700
  45. package/data/characters/helminth.json +220 -220
  46. package/data/characters/hildryn.json +627 -627
  47. package/data/characters/hildrynprime.json +996 -996
  48. package/data/characters/hydroid.json +1028 -1028
  49. package/data/characters/hydroidprime.json +1621 -1621
  50. package/data/characters/inaros.json +976 -976
  51. package/data/characters/inarosprime.json +1721 -1721
  52. package/data/characters/itzal.json +465 -465
  53. package/data/characters/ivara.json +1349 -1349
  54. package/data/characters/ivaraprime.json +1921 -1921
  55. package/data/characters/jade.json +240 -240
  56. package/data/characters/khora.json +804 -804
  57. package/data/characters/khoraprime.json +1403 -1403
  58. package/data/characters/koumei.json +221 -221
  59. package/data/characters/kullervo.json +191 -191
  60. package/data/characters/lavos.json +511 -511
  61. package/data/characters/lavosprime.json +711 -711
  62. package/data/characters/limbo.json +1363 -1363
  63. package/data/characters/limboprime.json +2084 -2084
  64. package/data/characters/loki.json +1209 -1209
  65. package/data/characters/lokiprime.json +1457 -1457
  66. package/data/characters/mag.json +2089 -2089
  67. package/data/characters/magprime.json +2532 -2532
  68. package/data/characters/mesa.json +1195 -1195
  69. package/data/characters/mesaprime.json +2059 -2059
  70. package/data/characters/mirage.json +1561 -1561
  71. package/data/characters/mirageprime.json +2257 -2257
  72. package/data/characters/narin.json +43 -43
  73. package/data/characters/nekros.json +1235 -1235
  74. package/data/characters/nekrosprime.json +1882 -1882
  75. package/data/characters/nezha.json +868 -868
  76. package/data/characters/nezhaprime.json +1695 -1695
  77. package/data/characters/nidus.json +1027 -1027
  78. package/data/characters/nidusprime.json +1706 -1706
  79. package/data/characters/nokko.json +308 -308
  80. package/data/characters/nova.json +1226 -1226
  81. package/data/characters/novaprime.json +1667 -1667
  82. package/data/characters/nyx.json +1610 -1610
  83. package/data/characters/nyxprime.json +1967 -1967
  84. package/data/characters/oberon.json +1249 -1249
  85. package/data/characters/oberonprime.json +1872 -1872
  86. package/data/characters/octavia.json +1049 -1049
  87. package/data/characters/octaviaprime.json +1735 -1735
  88. package/data/characters/odonata.json +336 -336
  89. package/data/characters/odonataprime.json +787 -787
  90. package/data/characters/oraxia.json +246 -246
  91. package/data/characters/orionsirius.json +134 -134
  92. package/data/characters/protea.json +521 -521
  93. package/data/characters/proteaprime.json +992 -992
  94. package/data/characters/qorvex.json +214 -214
  95. package/data/characters/revenant.json +931 -931
  96. package/data/characters/revenantprime.json +1494 -1494
  97. package/data/characters/rhino.json +1344 -1344
  98. package/data/characters/rhinoprime.json +1675 -1675
  99. package/data/characters/saryn.json +1210 -1210
  100. package/data/characters/sarynprime.json +2042 -2042
  101. package/data/characters/sevagoth.json +478 -478
  102. package/data/characters/sevagothprime.json +910 -910
  103. package/data/characters/siriusorion.json +247 -247
  104. package/data/characters/styanax.json +320 -320
  105. package/data/characters/styanaxprime.json +321 -321
  106. package/data/characters/temple.json +271 -271
  107. package/data/characters/titania.json +1227 -1227
  108. package/data/characters/titaniaprime.json +2000 -2000
  109. package/data/characters/trinity.json +1097 -1097
  110. package/data/characters/trinityprime.json +1849 -1849
  111. package/data/characters/uriel.json +215 -215
  112. package/data/characters/valkyr.json +1165 -1165
  113. package/data/characters/valkyrprime.json +1969 -1969
  114. package/data/characters/vauban.json +1067 -1067
  115. package/data/characters/vaubanprime.json +1614 -1614
  116. package/data/characters/voidrig.json +355 -355
  117. package/data/characters/volt.json +1324 -1324
  118. package/data/characters/voltprime.json +1843 -1843
  119. package/data/characters/voruna.json +293 -293
  120. package/data/characters/vorunaprime.json +431 -431
  121. package/data/characters/wisp.json +824 -824
  122. package/data/characters/wispprime.json +908 -908
  123. package/data/characters/wukong.json +927 -927
  124. package/data/characters/wukongprime.json +1754 -1754
  125. package/data/characters/xaku.json +620 -620
  126. package/data/characters/xakuprime.json +879 -879
  127. package/data/characters/yareli.json +409 -409
  128. package/data/characters/yareliprime.json +683 -683
  129. package/data/characters/zephyr.json +1149 -1149
  130. package/data/characters/zephyrprime.json +1864 -1864
  131. package/data/codes/warframe.json +20824 -20824
  132. package/data/meta.json +14 -14
  133. package/index.js +11 -11
  134. package/package.json +50 -50
  135. package/src/catalog.js +105 -105
  136. package/src/client-error.js +9 -9
  137. package/src/data-source.js +7 -7
  138. package/src/data-store.js +138 -138
  139. package/src/provider.js +22 -22
  140. package/src/tracker.js +115 -115
  141. package/src/warframe-client.js +57 -57
  142. package/src/warframe-codes.js +82 -82
  143. package/src/wfcd.js +93 -93
package/src/data-store.js CHANGED
@@ -1,138 +1,138 @@
1
- 'use strict';
2
- const fs = require('node:fs/promises');
3
- const path = require('node:path');
4
- const os = require('node:os');
5
-
6
- function safePath(value) {
7
- if (typeof value !== 'string' || !value || value.includes('\\') || value.includes('\0') ||
8
- value.startsWith('/') || value.split('/').some(v => !v || v === '.' || v === '..') ||
9
- !/^[a-zA-Z0-9_./-]+$/.test(value)) throw new TypeError('Unsafe JSON data path');
10
- if (!value.endsWith('.json')) throw new TypeError('Only .json files are supported');
11
- return value;
12
- }
13
- function sourceConfig(source) {
14
- if (!source) return null;
15
- const { owner, repo, branch = 'main', directory } = source;
16
- for (const part of [owner, repo, branch]) {
17
- if (typeof part !== 'string' || !/^[a-zA-Z0-9._-]+$/.test(part)) throw new TypeError('Invalid GitHub repository source');
18
- }
19
- if (typeof directory !== 'string' || !directory || directory.split('/').some(x => !x || x === '.' || x === '..') ||
20
- !/^[a-zA-Z0-9_/-]+$/.test(directory)) throw new TypeError('Invalid GitHub source directory');
21
- return { owner, repo, branch, directory };
22
- }
23
- function createDataStore({ gameId, cacheDir, bundledDir, github, githubToken, syncFiles, fetchImpl = globalThis.fetch } = {}) {
24
- if (typeof gameId !== 'string' || !/^[a-z0-9][a-z0-9_-]*$/.test(gameId)) throw new TypeError('Valid gameId required');
25
- if (typeof fetchImpl !== 'function') throw new TypeError('fetch is required');
26
- const source = sourceConfig(github);
27
- if (syncFiles !== undefined && (!Array.isArray(syncFiles) || syncFiles.length === 0)) throw new TypeError('syncFiles must be a nonempty array of JSON paths');
28
- const whitelist = syncFiles === undefined ? null : new Set(syncFiles.map(safePath));
29
- if (githubToken !== undefined && (typeof githubToken !== 'string' || !githubToken.trim())) throw new TypeError('githubToken must be a nonempty string');
30
- const githubHeaders = { Accept: 'application/vnd.github+json', 'User-Agent': 'NekoSune-Game-Tracker', ...(githubToken ? { Authorization: 'Bearer ' + githubToken } : {}) };
31
- const cache = path.resolve(cacheDir || path.join(os.homedir(), '.cache', 'nekosune-game-tracker', gameId));
32
- const bundled = bundledDir ? path.resolve(bundledDir) : null;
33
- const manifestFile = path.join(cache, '.manifest.json');
34
- async function loadManifest() {
35
- try {
36
- const current = JSON.parse(await fs.readFile(manifestFile, 'utf8'));
37
- if (current.source !== JSON.stringify({source, files: syncFiles || null}) || !current.files || typeof current.files !== 'object') return {};
38
- return current.files;
39
- } catch (err) { if (err.code === 'ENOENT' || err instanceof SyntaxError) return {}; throw err; }
40
- }
41
- async function atomicWrite(file, content) {
42
- await fs.mkdir(path.dirname(file), { recursive: true });
43
- const tmp = file + '.' + process.pid + '.' + require('node:crypto').randomBytes(6).toString('hex') + '.tmp';
44
- try { await fs.writeFile(tmp, content); await fs.rename(tmp, file); }
45
- catch (err) { await fs.rm(tmp, { force: true }); throw err; }
46
- }
47
- async function get(file) {
48
- const relative = safePath(file);
49
- for (const base of [cache, bundled].filter(Boolean)) {
50
- try { return JSON.parse(await fs.readFile(path.join(base, relative), 'utf8')); }
51
- catch (err) { if (err.code !== 'ENOENT') throw err; }
52
- }
53
- throw Object.assign(new Error('Local JSON not found: ' + relative), { code: 'ENOENT' });
54
- }
55
- async function list() {
56
- const results = new Set();
57
- async function walk(dir, relative = '') {
58
- let entries;
59
- try { entries = await fs.readdir(dir, { withFileTypes: true }); }
60
- catch (err) { if (err.code === 'ENOENT') return; throw err; }
61
- for (const entry of entries) {
62
- if (entry.isSymbolicLink()) continue;
63
- const rel = relative ? relative + '/' + entry.name : entry.name;
64
- if (entry.isDirectory()) await walk(path.join(dir, entry.name), rel);
65
- else if (entry.isFile() && rel.endsWith('.json') && rel !== '.manifest.json') {
66
- try { safePath(rel); results.add(rel); } catch {}
67
- }
68
- }
69
- }
70
- if (bundled) await walk(bundled);
71
- await walk(cache);
72
- return [...results].sort();
73
- }
74
- async function sync({ signal } = {}) {
75
- if (!source) throw new Error('No GitHub JSON source configured in this tracker branch');
76
- const { owner, repo, branch, directory } = source;
77
- const api = 'https://api.github.com/repos/' + owner + '/' + repo + '/git/trees/' + encodeURIComponent(branch) + '?recursive=1';
78
- const request = await fetchImpl(api, { headers: githubHeaders, signal });
79
- if (!request.ok) throw new Error('GitHub tree request failed: HTTP ' + request.status);
80
- const tree = await request.json();
81
- if (tree.truncated || !Array.isArray(tree.tree)) throw new Error('GitHub directory listing incomplete; refusing partial sync');
82
- const prefix = directory + '/';
83
- const files = tree.tree.filter(item => item.type === 'blob' && item.path.startsWith(prefix) && item.path.endsWith('.json') && (!whitelist || whitelist.has(item.path.slice(prefix.length))))
84
- .map(item => ({ name: safePath(item.path.slice(prefix.length)), sha: item.sha, size: item.size }));
85
- if (files.length > 5000) throw new Error('Too many JSON files');
86
- const old = await loadManifest();
87
- const next = { ...old };
88
- let updated = 0, unchanged = 0;
89
- for (const item of files) {
90
- if (!/^[0-9a-f]{40}$/.test(item.sha) || item.size > 5 * 1024 * 1024) throw new Error('Invalid or oversized GitHub JSON blob');
91
- const target = path.join(cache, item.name);
92
- if (old[item.name] === item.sha) {
93
- try {
94
- const existing = await fs.readFile(target);
95
- const existingHash = require('node:crypto').createHash('sha1')
96
- .update('blob ' + existing.length + '\0').update(existing).digest('hex');
97
- JSON.parse(existing.toString('utf8'));
98
- if (existingHash === item.sha) { unchanged++; continue; }
99
- } catch (err) {
100
- if (err.code !== 'ENOENT' && !(err instanceof SyntaxError)) throw err;
101
- }
102
- }
103
- // GitHub Git Blob API supports private repositories with a fine-grained read token.
104
- // Fetch by content SHA so the downloaded file matches the exact tree snapshot.
105
- const blobUrl = 'https://api.github.com/repos/' + owner + '/' + repo + '/git/blobs/' + item.sha;
106
- const response = await fetchImpl(blobUrl, { headers: githubHeaders, signal });
107
- if (!response.ok) throw new Error('GitHub JSON download failed (' + item.name + '): HTTP ' + response.status);
108
- const blob = await response.json();
109
- if (blob.encoding !== 'base64' || typeof blob.content !== 'string')
110
- throw new Error('Unexpected GitHub blob encoding for ' + item.name);
111
- const buffer = Buffer.from(blob.content.replace(/\s/g, ''), 'base64');
112
- if (buffer.length > 5 * 1024 * 1024) throw new Error('JSON file too large');
113
- const gitHash = require('node:crypto').createHash('sha1').update('blob ' + buffer.length + '\0').update(buffer).digest('hex');
114
- if (gitHash !== item.sha) throw new Error('GitHub JSON checksum mismatch: ' + item.name);
115
- JSON.parse(buffer.toString('utf8'));
116
- await atomicWrite(target, buffer);
117
- next[item.name] = item.sha;
118
- updated++;
119
- }
120
- // Cache is additive: do not automatically delete removed upstream files or user files.
121
- await atomicWrite(manifestFile, JSON.stringify({ source: JSON.stringify({source, files: syncFiles || null}), files: next }, null, 2) + '\n');
122
- return { updated, unchanged, checked: files.length, cacheDir: cache };
123
- }
124
- // Ensures a newly published remote JSON record is available even when it
125
- // was absent from the npm package and from this machine's cache.
126
- async function ensure(file, { signal, refresh = false } = {}) {
127
- const relative = safePath(file);
128
- if (!refresh) {
129
- try { return await get(relative); }
130
- catch (err) { if (err.code !== 'ENOENT') throw err; }
131
- }
132
- if (!source) return get(relative);
133
- await sync({ signal });
134
- return get(relative);
135
- }
136
- return Object.freeze({ get, list, sync, ensure, cacheDir: cache });
137
- }
138
- module.exports = { createDataStore };
1
+ 'use strict';
2
+ const fs = require('node:fs/promises');
3
+ const path = require('node:path');
4
+ const os = require('node:os');
5
+
6
+ function safePath(value) {
7
+ if (typeof value !== 'string' || !value || value.includes('\\') || value.includes('\0') ||
8
+ value.startsWith('/') || value.split('/').some(v => !v || v === '.' || v === '..') ||
9
+ !/^[a-zA-Z0-9_./-]+$/.test(value)) throw new TypeError('Unsafe JSON data path');
10
+ if (!value.endsWith('.json')) throw new TypeError('Only .json files are supported');
11
+ return value;
12
+ }
13
+ function sourceConfig(source) {
14
+ if (!source) return null;
15
+ const { owner, repo, branch = 'main', directory } = source;
16
+ for (const part of [owner, repo, branch]) {
17
+ if (typeof part !== 'string' || !/^[a-zA-Z0-9._-]+$/.test(part)) throw new TypeError('Invalid GitHub repository source');
18
+ }
19
+ if (typeof directory !== 'string' || !directory || directory.split('/').some(x => !x || x === '.' || x === '..') ||
20
+ !/^[a-zA-Z0-9_/-]+$/.test(directory)) throw new TypeError('Invalid GitHub source directory');
21
+ return { owner, repo, branch, directory };
22
+ }
23
+ function createDataStore({ gameId, cacheDir, bundledDir, github, githubToken, syncFiles, fetchImpl = globalThis.fetch } = {}) {
24
+ if (typeof gameId !== 'string' || !/^[a-z0-9][a-z0-9_-]*$/.test(gameId)) throw new TypeError('Valid gameId required');
25
+ if (typeof fetchImpl !== 'function') throw new TypeError('fetch is required');
26
+ const source = sourceConfig(github);
27
+ if (syncFiles !== undefined && (!Array.isArray(syncFiles) || syncFiles.length === 0)) throw new TypeError('syncFiles must be a nonempty array of JSON paths');
28
+ const whitelist = syncFiles === undefined ? null : new Set(syncFiles.map(safePath));
29
+ if (githubToken !== undefined && (typeof githubToken !== 'string' || !githubToken.trim())) throw new TypeError('githubToken must be a nonempty string');
30
+ const githubHeaders = { Accept: 'application/vnd.github+json', 'User-Agent': 'NekoSune-Game-Tracker', ...(githubToken ? { Authorization: 'Bearer ' + githubToken } : {}) };
31
+ const cache = path.resolve(cacheDir || path.join(os.homedir(), '.cache', 'nekosune-game-tracker', gameId));
32
+ const bundled = bundledDir ? path.resolve(bundledDir) : null;
33
+ const manifestFile = path.join(cache, '.manifest.json');
34
+ async function loadManifest() {
35
+ try {
36
+ const current = JSON.parse(await fs.readFile(manifestFile, 'utf8'));
37
+ if (current.source !== JSON.stringify({source, files: syncFiles || null}) || !current.files || typeof current.files !== 'object') return {};
38
+ return current.files;
39
+ } catch (err) { if (err.code === 'ENOENT' || err instanceof SyntaxError) return {}; throw err; }
40
+ }
41
+ async function atomicWrite(file, content) {
42
+ await fs.mkdir(path.dirname(file), { recursive: true });
43
+ const tmp = file + '.' + process.pid + '.' + require('node:crypto').randomBytes(6).toString('hex') + '.tmp';
44
+ try { await fs.writeFile(tmp, content); await fs.rename(tmp, file); }
45
+ catch (err) { await fs.rm(tmp, { force: true }); throw err; }
46
+ }
47
+ async function get(file) {
48
+ const relative = safePath(file);
49
+ for (const base of [cache, bundled].filter(Boolean)) {
50
+ try { return JSON.parse(await fs.readFile(path.join(base, relative), 'utf8')); }
51
+ catch (err) { if (err.code !== 'ENOENT') throw err; }
52
+ }
53
+ throw Object.assign(new Error('Local JSON not found: ' + relative), { code: 'ENOENT' });
54
+ }
55
+ async function list() {
56
+ const results = new Set();
57
+ async function walk(dir, relative = '') {
58
+ let entries;
59
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); }
60
+ catch (err) { if (err.code === 'ENOENT') return; throw err; }
61
+ for (const entry of entries) {
62
+ if (entry.isSymbolicLink()) continue;
63
+ const rel = relative ? relative + '/' + entry.name : entry.name;
64
+ if (entry.isDirectory()) await walk(path.join(dir, entry.name), rel);
65
+ else if (entry.isFile() && rel.endsWith('.json') && rel !== '.manifest.json') {
66
+ try { safePath(rel); results.add(rel); } catch {}
67
+ }
68
+ }
69
+ }
70
+ if (bundled) await walk(bundled);
71
+ await walk(cache);
72
+ return [...results].sort();
73
+ }
74
+ async function sync({ signal } = {}) {
75
+ if (!source) throw new Error('No GitHub JSON source configured in this tracker branch');
76
+ const { owner, repo, branch, directory } = source;
77
+ const api = 'https://api.github.com/repos/' + owner + '/' + repo + '/git/trees/' + encodeURIComponent(branch) + '?recursive=1';
78
+ const request = await fetchImpl(api, { headers: githubHeaders, signal });
79
+ if (!request.ok) throw new Error('GitHub tree request failed: HTTP ' + request.status);
80
+ const tree = await request.json();
81
+ if (tree.truncated || !Array.isArray(tree.tree)) throw new Error('GitHub directory listing incomplete; refusing partial sync');
82
+ const prefix = directory + '/';
83
+ const files = tree.tree.filter(item => item.type === 'blob' && item.path.startsWith(prefix) && item.path.endsWith('.json') && (!whitelist || whitelist.has(item.path.slice(prefix.length))))
84
+ .map(item => ({ name: safePath(item.path.slice(prefix.length)), sha: item.sha, size: item.size }));
85
+ if (files.length > 5000) throw new Error('Too many JSON files');
86
+ const old = await loadManifest();
87
+ const next = { ...old };
88
+ let updated = 0, unchanged = 0;
89
+ for (const item of files) {
90
+ if (!/^[0-9a-f]{40}$/.test(item.sha) || item.size > 5 * 1024 * 1024) throw new Error('Invalid or oversized GitHub JSON blob');
91
+ const target = path.join(cache, item.name);
92
+ if (old[item.name] === item.sha) {
93
+ try {
94
+ const existing = await fs.readFile(target);
95
+ const existingHash = require('node:crypto').createHash('sha1')
96
+ .update('blob ' + existing.length + '\0').update(existing).digest('hex');
97
+ JSON.parse(existing.toString('utf8'));
98
+ if (existingHash === item.sha) { unchanged++; continue; }
99
+ } catch (err) {
100
+ if (err.code !== 'ENOENT' && !(err instanceof SyntaxError)) throw err;
101
+ }
102
+ }
103
+ // GitHub Git Blob API supports private repositories with a fine-grained read token.
104
+ // Fetch by content SHA so the downloaded file matches the exact tree snapshot.
105
+ const blobUrl = 'https://api.github.com/repos/' + owner + '/' + repo + '/git/blobs/' + item.sha;
106
+ const response = await fetchImpl(blobUrl, { headers: githubHeaders, signal });
107
+ if (!response.ok) throw new Error('GitHub JSON download failed (' + item.name + '): HTTP ' + response.status);
108
+ const blob = await response.json();
109
+ if (blob.encoding !== 'base64' || typeof blob.content !== 'string')
110
+ throw new Error('Unexpected GitHub blob encoding for ' + item.name);
111
+ const buffer = Buffer.from(blob.content.replace(/\s/g, ''), 'base64');
112
+ if (buffer.length > 5 * 1024 * 1024) throw new Error('JSON file too large');
113
+ const gitHash = require('node:crypto').createHash('sha1').update('blob ' + buffer.length + '\0').update(buffer).digest('hex');
114
+ if (gitHash !== item.sha) throw new Error('GitHub JSON checksum mismatch: ' + item.name);
115
+ JSON.parse(buffer.toString('utf8'));
116
+ await atomicWrite(target, buffer);
117
+ next[item.name] = item.sha;
118
+ updated++;
119
+ }
120
+ // Cache is additive: do not automatically delete removed upstream files or user files.
121
+ await atomicWrite(manifestFile, JSON.stringify({ source: JSON.stringify({source, files: syncFiles || null}), files: next }, null, 2) + '\n');
122
+ return { updated, unchanged, checked: files.length, cacheDir: cache };
123
+ }
124
+ // Ensures a newly published remote JSON record is available even when it
125
+ // was absent from the npm package and from this machine's cache.
126
+ async function ensure(file, { signal, refresh = false } = {}) {
127
+ const relative = safePath(file);
128
+ if (!refresh) {
129
+ try { return await get(relative); }
130
+ catch (err) { if (err.code !== 'ENOENT') throw err; }
131
+ }
132
+ if (!source) return get(relative);
133
+ await sync({ signal });
134
+ return get(relative);
135
+ }
136
+ return Object.freeze({ get, list, sync, ensure, cacheDir: cache });
137
+ }
138
+ module.exports = { createDataStore };
package/src/provider.js CHANGED
@@ -1,22 +1,22 @@
1
- 'use strict';
2
- const BASE = 'https://api.warframestat.us/';
3
- const PLATFORMS = Object.freeze(['pc','ps4','psn','xb1','swi','ns']);
4
- const WORLDSTATE_FIELDS = Object.freeze([
5
- 'alerts','arbitration','archonHunt','cambionCycle','cetusCycle','conclaveChallenges',
6
- 'constructionProgress','dailyDeals','darvo','earthCycle','events','fissures',
7
- 'flashSales','globalUpgrades','invasions','kuva','news','nightwave','persistentEnemies',
8
- 'sentientOutposts','simaris','sortie','steelPath','syndicateMissions','thermia',
9
- 'vallisCycle','voidTrader','weeklyChallenges'
10
- ]);
11
- const STATIC_KEYS = Object.freeze([
12
- 'arcanes','synthTargets','solNodes','conclave','events','operationTypes',
13
- 'persistentEnemy','sortie','upgradeTypes','fissureModifiers','archonShards',
14
- 'factions','missionTypes','languages','locales','mods','weapons','warframes','items'
15
- ]);
16
- module.exports = Object.freeze({
17
- baseUrl: BASE,
18
- auth: Object.freeze({type:'none'}),
19
- platforms: PLATFORMS,
20
- worldstateFields: WORLDSTATE_FIELDS,
21
- staticKeys: STATIC_KEYS
22
- });
1
+ 'use strict';
2
+ const BASE = 'https://api.warframestat.us/';
3
+ const PLATFORMS = Object.freeze(['pc','ps4','psn','xb1','swi','ns']);
4
+ const WORLDSTATE_FIELDS = Object.freeze([
5
+ 'alerts','arbitration','archonHunt','cambionCycle','cetusCycle','conclaveChallenges',
6
+ 'constructionProgress','dailyDeals','darvo','earthCycle','events','fissures',
7
+ 'flashSales','globalUpgrades','invasions','kuva','news','nightwave','persistentEnemies',
8
+ 'sentientOutposts','simaris','sortie','steelPath','syndicateMissions','thermia',
9
+ 'vallisCycle','voidTrader','weeklyChallenges'
10
+ ]);
11
+ const STATIC_KEYS = Object.freeze([
12
+ 'arcanes','synthTargets','solNodes','conclave','events','operationTypes',
13
+ 'persistentEnemy','sortie','upgradeTypes','fissureModifiers','archonShards',
14
+ 'factions','missionTypes','languages','locales','mods','weapons','warframes','items'
15
+ ]);
16
+ module.exports = Object.freeze({
17
+ baseUrl: BASE,
18
+ auth: Object.freeze({type:'none'}),
19
+ platforms: PLATFORMS,
20
+ worldstateFields: WORLDSTATE_FIELDS,
21
+ staticKeys: STATIC_KEYS
22
+ });
package/src/tracker.js CHANGED
@@ -1,115 +1,115 @@
1
- 'use strict';
2
- const provider=require('./provider');
3
- const {createWarframeClient,parameter,optionsQuery}=require('./warframe-client');
4
- const {createDataStore}=require('./data-store');
5
- const {createCatalog}=require('./catalog');
6
- const {createWarframeCodes}=require('./warframe-codes');
7
- const {createWFCD,createMarketQuery,createUpdateMonitor}=require('./wfcd');
8
- const {TrackerError}=require('./client-error');
9
-
10
- const itemTypes=new Set(['warframes','weapons','items','mods']);
11
- const id=v=>parameter(v,'identifier');
12
- function platformOf(value) {
13
- const p=value||'pc';
14
- if(!provider.platforms.includes(p))throw new RangeError('Unsupported platform '+p);
15
- return p;
16
- }
17
- function createWarframeTracker({platform='pc',language='en',timeoutMs=15000,cacheDir,githubToken,fetchImpl}={}) {
18
- const selected=platformOf(platform);
19
- const {request}=createWarframeClient({language,timeoutMs,fetchImpl});
20
- const data=createDataStore({
21
- gameId:'warframe', github:{owner:'NekoSuneProjectsNPM',repo:'game-tracker',branch:'JsonData',directory:'games/warframe'},
22
- bundledDir:require('node:path').join(__dirname,'..','data'),
23
- cacheDir, githubToken, fetchImpl
24
- });
25
- const catalog=createCatalog(data);
26
- // Local-first: only query GitHub if a requested character is absent.
27
- // Existing bundled and cached characters work without any network call.
28
- async function character(name, options = {}) {
29
- try { return await catalog.character(name); }
30
- catch (err) {
31
- if (err.code !== 'NOT_FOUND') throw err;
32
- if (options.refresh === false) throw err;
33
- await data.sync({ signal: options.signal });
34
- return catalog.character(name);
35
- }
36
- }
37
- // Set refresh:true to discover new records that didn't exist in local JSON.
38
- async function search(query, options = {}) {
39
- if (options.refresh === true) await data.sync({ signal: options.signal });
40
- return catalog.search(query, options);
41
- }
42
- const codes=createWarframeCodes({cacheDir,githubToken,fetchImpl});
43
- const wfcd=createWFCD({cacheDir,githubToken,fetchImpl});
44
- const worldstate=(field,options={})=>{
45
- if(typeof field!=='string' || !/^[a-zA-Z][a-zA-Z0-9]*$/.test(field)) throw new TypeError('Invalid worldstate field');
46
- return request(selected+'/'+field,optionsQuery(options,['filter']));
47
- };
48
- const listing=(kind,options={})=>{
49
- if(!itemTypes.has(kind))throw new RangeError('Unknown item type');
50
- return request(kind,optionsQuery(options,['language','filter','only','remove']));
51
- };
52
- const single=(kind,name,options={})=>{
53
- if(!itemTypes.has(kind))throw new RangeError('Unknown item type');
54
- return request(kind+'/'+id(name),optionsQuery(options,['language','filter','only','remove','by']));
55
- };
56
- const find=(kind,query,options={})=>{
57
- if(!itemTypes.has(kind))throw new RangeError('Unknown item type');
58
- return request(kind+'/search/'+id(query),optionsQuery(options,['language','filter','only','remove','by']));
59
- };
60
- const api=Object.freeze({
61
- health:()=>request('health'),
62
- worldstate:()=>request(selected),
63
- worldstateField:(field,options)=>worldstate(field,options),
64
- profile:(playerId,options)=>request('profile/'+id(playerId),optionsQuery(options,['language','withItem'])),
65
- profileStats:playerId=>request('profile/'+id(playerId)+'/stats'),
66
- profileXP:(playerId,options)=>request('profile/'+id(playerId)+'/xpInfo',optionsQuery(options,['withItem'])),
67
- list:listing,
68
- get:single,
69
- search:find,
70
- warframes:options=>listing('warframes',options),
71
- weapons:options=>listing('weapons',options),
72
- items:options=>listing('items',options),
73
- mods:options=>listing('mods',options),
74
- warframe:(name,options)=>single('warframes',name,options),
75
- weapon:(name,options)=>single('weapons',name,options),
76
- item:(name,options)=>single('items',name,options),
77
- mod:(name,options)=>single('mods',name,options),
78
- searchWarframes:(q,options)=>find('warframes',q,options),
79
- searchWeapons:(q,options)=>find('weapons',q,options),
80
- searchItems:(q,options)=>find('items',q,options),
81
- searchMods:(q,options)=>find('mods',q,options),
82
- drops:()=>request('drops'),
83
- searchDrops:(query,options)=>request('drops/search/'+id(query),optionsQuery(options,['grouped_by'])),
84
- rivens:()=>request(selected+'/rivens'),
85
- searchRivens:query=>request(selected+'/rivens/search/'+id(query)),
86
- staticData:(key,options)=>request(id(key),optionsQuery(options,['language'])),
87
- searchStatic:(key,query,options)=>request(id(key)+'/search/'+id(query),optionsQuery(options,['language'])),
88
- synthTargets:options=>request('synthTargets',optionsQuery(options,['language'])),
89
- arcanes:options=>request('arcanes',optionsQuery(options,['language'])),
90
- rss:()=>request('rss'),
91
- twitter:()=>request('twitter'),
92
- wfinfoFilteredItems:()=>request('wfinfo/filtered_items'),
93
- wfinfoPrices:()=>request('wfinfo/prices')
94
- });
95
- const fields=Object.fromEntries(provider.worldstateFields.filter(k=>!Object.hasOwn(api,k)).map(k=>[k,options=>worldstate(k,options)]));
96
- const fullApi=Object.freeze({...api,...fields});
97
- return Object.freeze({
98
- api:fullApi,
99
- data,
100
- codes,
101
- wfcd,
102
- market:createMarketQuery,
103
- createUpdateMonitor,
104
- code:codes.get,
105
- About:()=>data.get('meta.json'),
106
- search,
107
- character,
108
- record:catalog.get,
109
- item:catalog.item,
110
- map:catalog.map,
111
- // live player endpoints remain under api.* to distinguish local records
112
- getPlayerStats:fullApi.profileStats
113
- });
114
- }
115
- module.exports={createTracker:createWarframeTracker,TrackerError};
1
+ 'use strict';
2
+ const provider=require('./provider');
3
+ const {createWarframeClient,parameter,optionsQuery}=require('./warframe-client');
4
+ const {createDataStore}=require('./data-store');
5
+ const {createCatalog}=require('./catalog');
6
+ const {createWarframeCodes}=require('./warframe-codes');
7
+ const {createWFCD,createMarketQuery,createUpdateMonitor}=require('./wfcd');
8
+ const {TrackerError}=require('./client-error');
9
+
10
+ const itemTypes=new Set(['warframes','weapons','items','mods']);
11
+ const id=v=>parameter(v,'identifier');
12
+ function platformOf(value) {
13
+ const p=value||'pc';
14
+ if(!provider.platforms.includes(p))throw new RangeError('Unsupported platform '+p);
15
+ return p;
16
+ }
17
+ function createWarframeTracker({platform='pc',language='en',timeoutMs=15000,cacheDir,githubToken,fetchImpl}={}) {
18
+ const selected=platformOf(platform);
19
+ const {request}=createWarframeClient({language,timeoutMs,fetchImpl});
20
+ const data=createDataStore({
21
+ gameId:'warframe', github:{owner:'NekoSuneProjectsNPM',repo:'game-tracker',branch:'JsonData',directory:'games/warframe'},
22
+ bundledDir:require('node:path').join(__dirname,'..','data'),
23
+ cacheDir, githubToken, fetchImpl
24
+ });
25
+ const catalog=createCatalog(data);
26
+ // Local-first: only query GitHub if a requested character is absent.
27
+ // Existing bundled and cached characters work without any network call.
28
+ async function character(name, options = {}) {
29
+ try { return await catalog.character(name); }
30
+ catch (err) {
31
+ if (err.code !== 'NOT_FOUND') throw err;
32
+ if (options.refresh === false) throw err;
33
+ await data.sync({ signal: options.signal });
34
+ return catalog.character(name);
35
+ }
36
+ }
37
+ // Set refresh:true to discover new records that didn't exist in local JSON.
38
+ async function search(query, options = {}) {
39
+ if (options.refresh === true) await data.sync({ signal: options.signal });
40
+ return catalog.search(query, options);
41
+ }
42
+ const codes=createWarframeCodes({cacheDir,githubToken,fetchImpl});
43
+ const wfcd=createWFCD({cacheDir,githubToken,fetchImpl});
44
+ const worldstate=(field,options={})=>{
45
+ if(typeof field!=='string' || !/^[a-zA-Z][a-zA-Z0-9]*$/.test(field)) throw new TypeError('Invalid worldstate field');
46
+ return request(selected+'/'+field,optionsQuery(options,['filter']));
47
+ };
48
+ const listing=(kind,options={})=>{
49
+ if(!itemTypes.has(kind))throw new RangeError('Unknown item type');
50
+ return request(kind,optionsQuery(options,['language','filter','only','remove']));
51
+ };
52
+ const single=(kind,name,options={})=>{
53
+ if(!itemTypes.has(kind))throw new RangeError('Unknown item type');
54
+ return request(kind+'/'+id(name),optionsQuery(options,['language','filter','only','remove','by']));
55
+ };
56
+ const find=(kind,query,options={})=>{
57
+ if(!itemTypes.has(kind))throw new RangeError('Unknown item type');
58
+ return request(kind+'/search/'+id(query),optionsQuery(options,['language','filter','only','remove','by']));
59
+ };
60
+ const api=Object.freeze({
61
+ health:()=>request('health'),
62
+ worldstate:()=>request(selected),
63
+ worldstateField:(field,options)=>worldstate(field,options),
64
+ profile:(playerId,options)=>request('profile/'+id(playerId),optionsQuery(options,['language','withItem'])),
65
+ profileStats:playerId=>request('profile/'+id(playerId)+'/stats'),
66
+ profileXP:(playerId,options)=>request('profile/'+id(playerId)+'/xpInfo',optionsQuery(options,['withItem'])),
67
+ list:listing,
68
+ get:single,
69
+ search:find,
70
+ warframes:options=>listing('warframes',options),
71
+ weapons:options=>listing('weapons',options),
72
+ items:options=>listing('items',options),
73
+ mods:options=>listing('mods',options),
74
+ warframe:(name,options)=>single('warframes',name,options),
75
+ weapon:(name,options)=>single('weapons',name,options),
76
+ item:(name,options)=>single('items',name,options),
77
+ mod:(name,options)=>single('mods',name,options),
78
+ searchWarframes:(q,options)=>find('warframes',q,options),
79
+ searchWeapons:(q,options)=>find('weapons',q,options),
80
+ searchItems:(q,options)=>find('items',q,options),
81
+ searchMods:(q,options)=>find('mods',q,options),
82
+ drops:()=>request('drops'),
83
+ searchDrops:(query,options)=>request('drops/search/'+id(query),optionsQuery(options,['grouped_by'])),
84
+ rivens:()=>request(selected+'/rivens'),
85
+ searchRivens:query=>request(selected+'/rivens/search/'+id(query)),
86
+ staticData:(key,options)=>request(id(key),optionsQuery(options,['language'])),
87
+ searchStatic:(key,query,options)=>request(id(key)+'/search/'+id(query),optionsQuery(options,['language'])),
88
+ synthTargets:options=>request('synthTargets',optionsQuery(options,['language'])),
89
+ arcanes:options=>request('arcanes',optionsQuery(options,['language'])),
90
+ rss:()=>request('rss'),
91
+ twitter:()=>request('twitter'),
92
+ wfinfoFilteredItems:()=>request('wfinfo/filtered_items'),
93
+ wfinfoPrices:()=>request('wfinfo/prices')
94
+ });
95
+ const fields=Object.fromEntries(provider.worldstateFields.filter(k=>!Object.hasOwn(api,k)).map(k=>[k,options=>worldstate(k,options)]));
96
+ const fullApi=Object.freeze({...api,...fields});
97
+ return Object.freeze({
98
+ api:fullApi,
99
+ data,
100
+ codes,
101
+ wfcd,
102
+ market:createMarketQuery,
103
+ createUpdateMonitor,
104
+ code:codes.get,
105
+ About:()=>data.get('meta.json'),
106
+ search,
107
+ character,
108
+ record:catalog.get,
109
+ item:catalog.item,
110
+ map:catalog.map,
111
+ // live player endpoints remain under api.* to distinguish local records
112
+ getPlayerStats:fullApi.profileStats
113
+ });
114
+ }
115
+ module.exports={createTracker:createWarframeTracker,TrackerError};