@lucidaquarian/app-version-checker 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/src/semver.js ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Semantic version comparison utilities.
3
+ * Handles parsing and comparing version strings (e.g. "1.2.3", "2.0.0-beta.1").
4
+ */
5
+
6
+ class SemVer {
7
+ /**
8
+ * Parse a version string into its components.
9
+ * @param {string} version - Version string (e.g., "1.2.3", "1.0.0-beta.1")
10
+ * @returns {{ major: number, minor: number, patch: number, prerelease: string }}
11
+ */
12
+ static parse(version) {
13
+ if (!version || typeof version !== 'string') {
14
+ throw new Error(`Invalid version string: "${version}"`);
15
+ }
16
+
17
+ const cleaned = version.trim().replace(/^v/i, '');
18
+
19
+ // Build metadata (after '+') is ignored for precedence; drop it first.
20
+ const withoutBuild = cleaned.split('+', 1)[0];
21
+
22
+ // Separate the version core from the prerelease at the FIRST hyphen only,
23
+ // so hyphenated prerelease identifiers (e.g. "beta-2") stay intact.
24
+ const hyphenIndex = withoutBuild.indexOf('-');
25
+ const versionPart = hyphenIndex === -1 ? withoutBuild : withoutBuild.slice(0, hyphenIndex);
26
+ const prerelease = hyphenIndex === -1 ? '' : withoutBuild.slice(hyphenIndex + 1);
27
+
28
+ const parts = versionPart.split('.');
29
+
30
+ // Major must be present and numeric; minor/patch default to 0 when omitted.
31
+ // Non-numeric components are a hard error (previously they were silently
32
+ // coerced to 0, which let garbage like "abc" parse as 0.0.0).
33
+ const toComponent = (raw, required) => {
34
+ if (raw === undefined || raw === '') {
35
+ if (required) throw new Error(`Unable to parse version: "${version}"`);
36
+ return 0;
37
+ }
38
+ if (!/^\d+$/.test(raw)) {
39
+ throw new Error(`Unable to parse version: "${version}"`);
40
+ }
41
+ return parseInt(raw, 10);
42
+ };
43
+
44
+ const major = toComponent(parts[0], true);
45
+ const minor = toComponent(parts[1], false);
46
+ const patch = toComponent(parts[2], false);
47
+
48
+ return { major, minor, patch, prerelease };
49
+ }
50
+
51
+ /**
52
+ * Compare two prerelease strings per the SemVer precedence rules:
53
+ * dot-separated identifiers compared left to right, numeric identifiers
54
+ * compared numerically, numeric ranking lower than alphanumeric, and a
55
+ * larger set of identifiers ranking higher when all preceding are equal.
56
+ * @param {string} a - Non-empty prerelease string
57
+ * @param {string} b - Non-empty prerelease string
58
+ * @returns {number} -1, 0, or 1
59
+ * @private
60
+ */
61
+ static _comparePrerelease(a, b) {
62
+ const aIds = a.split('.');
63
+ const bIds = b.split('.');
64
+ const len = Math.max(aIds.length, bIds.length);
65
+
66
+ for (let i = 0; i < len; i++) {
67
+ const aId = aIds[i];
68
+ const bId = bIds[i];
69
+
70
+ // A larger set of identifiers has higher precedence.
71
+ if (aId === undefined) return -1;
72
+ if (bId === undefined) return 1;
73
+
74
+ const aNum = /^\d+$/.test(aId);
75
+ const bNum = /^\d+$/.test(bId);
76
+
77
+ if (aNum && bNum) {
78
+ const diff = parseInt(aId, 10) - parseInt(bId, 10);
79
+ if (diff !== 0) return diff < 0 ? -1 : 1;
80
+ } else if (aNum !== bNum) {
81
+ // Numeric identifiers have lower precedence than alphanumeric ones.
82
+ return aNum ? -1 : 1;
83
+ } else if (aId !== bId) {
84
+ return aId < bId ? -1 : 1;
85
+ }
86
+ }
87
+
88
+ return 0;
89
+ }
90
+
91
+ /**
92
+ * Compare two version strings.
93
+ * @param {string} versionA
94
+ * @param {string} versionB
95
+ * @returns {number} -1 if A < B, 0 if A == B, 1 if A > B
96
+ */
97
+ static compare(versionA, versionB) {
98
+ const a = SemVer.parse(versionA);
99
+ const b = SemVer.parse(versionB);
100
+
101
+ if (a.major !== b.major) return a.major > b.major ? 1 : -1;
102
+ if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1;
103
+ if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1;
104
+
105
+ // Pre-release versions have lower precedence than the release version
106
+ if (a.prerelease && !b.prerelease) return -1;
107
+ if (!a.prerelease && b.prerelease) return 1;
108
+ if (a.prerelease && b.prerelease) {
109
+ return SemVer._comparePrerelease(a.prerelease, b.prerelease);
110
+ }
111
+
112
+ return 0;
113
+ }
114
+
115
+ /**
116
+ * Check if versionA is greater than versionB.
117
+ * @param {string} versionA
118
+ * @param {string} versionB
119
+ * @returns {boolean}
120
+ */
121
+ static gt(versionA, versionB) {
122
+ return SemVer.compare(versionA, versionB) === 1;
123
+ }
124
+
125
+ /**
126
+ * Check if versionA is less than versionB.
127
+ * @param {string} versionA
128
+ * @param {string} versionB
129
+ * @returns {boolean}
130
+ */
131
+ static lt(versionA, versionB) {
132
+ return SemVer.compare(versionA, versionB) === -1;
133
+ }
134
+
135
+ /**
136
+ * Check if versionA equals versionB.
137
+ * @param {string} versionA
138
+ * @param {string} versionB
139
+ * @returns {boolean}
140
+ */
141
+ static eq(versionA, versionB) {
142
+ return SemVer.compare(versionA, versionB) === 0;
143
+ }
144
+
145
+ /**
146
+ * Check if versionA >= versionB.
147
+ * @param {string} versionA
148
+ * @param {string} versionB
149
+ * @returns {boolean}
150
+ */
151
+ static gte(versionA, versionB) {
152
+ return SemVer.compare(versionA, versionB) >= 0;
153
+ }
154
+
155
+ /**
156
+ * Check if versionA <= versionB.
157
+ * @param {string} versionA
158
+ * @param {string} versionB
159
+ * @returns {boolean}
160
+ */
161
+ static lte(versionA, versionB) {
162
+ return SemVer.compare(versionA, versionB) <= 0;
163
+ }
164
+
165
+ /**
166
+ * Determine the type of update between two versions.
167
+ * @param {string} currentVersion
168
+ * @param {string} latestVersion
169
+ * @returns {'major' | 'minor' | 'patch' | 'prerelease' | 'none'}
170
+ */
171
+ static updateType(currentVersion, latestVersion) {
172
+ if (SemVer.gte(currentVersion, latestVersion)) return 'none';
173
+
174
+ const current = SemVer.parse(currentVersion);
175
+ const latest = SemVer.parse(latestVersion);
176
+
177
+ if (latest.major > current.major) return 'major';
178
+ if (latest.minor > current.minor) return 'minor';
179
+ if (latest.patch > current.patch) return 'patch';
180
+ return 'prerelease';
181
+ }
182
+
183
+ /**
184
+ * Validate whether a string is a valid semver.
185
+ * @param {string} version
186
+ * @returns {boolean}
187
+ */
188
+ static isValid(version) {
189
+ try {
190
+ SemVer.parse(version);
191
+ return true;
192
+ } catch {
193
+ return false;
194
+ }
195
+ }
196
+ }
197
+
198
+ module.exports = SemVer;
package/src/stores.js ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Store lookup strategies.
3
+ * Fetch the latest published version from the App Store, Google Play, or a custom endpoint.
4
+ */
5
+
6
+ /** Default network timeout (ms) applied to every store request. */
7
+ const DEFAULT_TIMEOUT_MS = 10000;
8
+
9
+ /**
10
+ * `fetch` wrapper that aborts the request after `timeoutMs`, so a hung
11
+ * connection can never make a version check hang indefinitely. Any caller
12
+ * supplied `options.signal` is still honored (aborting it aborts the request).
13
+ *
14
+ * @param {string} url
15
+ * @param {object} [options] - Standard fetch options; may include a `signal`.
16
+ * @param {number} [timeoutMs] - Timeout in ms. 0 or negative disables it.
17
+ * @returns {Promise<Response>}
18
+ */
19
+ async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
20
+ const controller = new AbortController();
21
+ const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
22
+
23
+ // Chain a caller-provided signal to our controller so either can abort.
24
+ const callerSignal = options.signal;
25
+ if (callerSignal) {
26
+ if (callerSignal.aborted) controller.abort();
27
+ else callerSignal.addEventListener('abort', () => controller.abort(), { once: true });
28
+ }
29
+
30
+ try {
31
+ return await fetch(url, { ...options, signal: controller.signal });
32
+ } catch (err) {
33
+ // Distinguish our timeout from a caller-initiated abort or a real error.
34
+ if (err && err.name === 'AbortError' && !(callerSignal && callerSignal.aborted)) {
35
+ throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`);
36
+ }
37
+ throw err;
38
+ } finally {
39
+ if (timer) clearTimeout(timer);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Fetch the latest version from Apple App Store using the iTunes Lookup API.
45
+ *
46
+ * @param {string} bundleId - The iOS bundle identifier (e.g. "com.example.app")
47
+ * @param {string} [country='us'] - Two-letter ISO country code
48
+ * @param {number} [timeoutMs] - Request timeout in ms
49
+ * @returns {Promise<{ version: string, releaseNotes: string, storeUrl: string, releaseDate: string, minimumOsVersion: string }>}
50
+ */
51
+ async function fetchAppStoreVersion(bundleId, country = 'us', timeoutMs = DEFAULT_TIMEOUT_MS) {
52
+ if (!bundleId) throw new Error('bundleId is required for App Store lookup');
53
+
54
+ const url = `https://itunes.apple.com/lookup?bundleId=${encodeURIComponent(bundleId)}&country=${encodeURIComponent(country)}`;
55
+
56
+ const response = await fetchWithTimeout(url, {}, timeoutMs);
57
+ if (!response.ok) {
58
+ throw new Error(`App Store lookup failed: HTTP ${response.status}`);
59
+ }
60
+
61
+ const data = await response.json();
62
+
63
+ if (!data.results || data.results.length === 0) {
64
+ throw new Error(
65
+ `App not found on the App Store with bundleId "${bundleId}" in country "${country}".`
66
+ );
67
+ }
68
+
69
+ const result = data.results[0];
70
+
71
+ if (!result.version) {
72
+ throw new Error(
73
+ `App Store result for bundleId "${bundleId}" did not include a version.`
74
+ );
75
+ }
76
+
77
+ return {
78
+ version: result.version,
79
+ releaseNotes: result.releaseNotes || '',
80
+ storeUrl: result.trackViewUrl || '',
81
+ releaseDate: result.currentVersionReleaseDate || '',
82
+ minimumOsVersion: result.minimumOsVersion || '',
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Fetch the latest version from Google Play Store.
88
+ * NOTE: Google does not provide an official public API for this.
89
+ * This uses a lightweight HTML-scraping approach as a fallback.
90
+ * For production use, prefer a custom server endpoint or the Google Play Developer API.
91
+ *
92
+ * @param {string} packageName - The Android package name (e.g. "com.example.app")
93
+ * @param {number} [timeoutMs] - Request timeout in ms
94
+ * @returns {Promise<{ version: string, storeUrl: string }>}
95
+ */
96
+ async function fetchPlayStoreVersion(packageName, timeoutMs = DEFAULT_TIMEOUT_MS) {
97
+ if (!packageName) throw new Error('packageName is required for Play Store lookup');
98
+
99
+ const storeUrl = `https://play.google.com/store/apps/details?id=${encodeURIComponent(packageName)}&hl=en`;
100
+
101
+ const response = await fetchWithTimeout(storeUrl, {}, timeoutMs);
102
+ if (!response.ok) {
103
+ throw new Error(`Play Store lookup failed: HTTP ${response.status}`);
104
+ }
105
+
106
+ const html = await response.text();
107
+
108
+ // Attempt to extract the version from the page content. This is fragile and
109
+ // may break if Google changes their markup, so several known shapes are
110
+ // tried before giving up. Prefer a custom endpoint for production use.
111
+ const patterns = [
112
+ /\[\[\["(\d+(?:\.\d+)+)"\]\]/, // primary: [[["1.2.3"]]]
113
+ /"version"\s*:\s*"(\d+(?:\.\d+)+)"/i, // JSON-ish "version":"1.2.3"
114
+ /Current Version[\s\S]{0,80}?(\d+(?:\.\d+)+)/i, // legacy label markup
115
+ ];
116
+
117
+ let version = null;
118
+ for (const pattern of patterns) {
119
+ const match = html.match(pattern);
120
+ if (match) {
121
+ version = match[1];
122
+ break;
123
+ }
124
+ }
125
+
126
+ if (!version) {
127
+ throw new Error(
128
+ 'Could not extract version from Play Store page. ' +
129
+ 'Consider using a custom endpoint instead.'
130
+ );
131
+ }
132
+
133
+ return {
134
+ version,
135
+ storeUrl,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Fetch the latest version from a custom remote endpoint.
141
+ * The endpoint should return JSON with at least a `version` field.
142
+ *
143
+ * Expected response format:
144
+ * {
145
+ * "version": "2.1.0",
146
+ * "minVersion": "1.5.0", // optional: minimum supported version
147
+ * "releaseNotes": "Bug fixes", // optional
148
+ * "storeUrl": "https://...", // optional: link to download/update
149
+ * "forceUpdate": false // optional: whether update is mandatory
150
+ * }
151
+ *
152
+ * @param {string} url - The endpoint URL
153
+ * @param {object} [options] - Fetch options (headers, etc.)
154
+ * @param {number} [timeoutMs] - Request timeout in ms
155
+ * @returns {Promise<object>}
156
+ */
157
+ async function fetchCustomEndpoint(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
158
+ if (!url) throw new Error('URL is required for custom endpoint lookup');
159
+
160
+ const response = await fetchWithTimeout(
161
+ url,
162
+ {
163
+ method: 'GET',
164
+ ...options,
165
+ headers: {
166
+ 'Accept': 'application/json',
167
+ ...(options.headers || {}),
168
+ },
169
+ },
170
+ timeoutMs
171
+ );
172
+
173
+ if (!response.ok) {
174
+ throw new Error(`Custom endpoint lookup failed: HTTP ${response.status}`);
175
+ }
176
+
177
+ const data = await response.json();
178
+
179
+ if (!data.version) {
180
+ throw new Error('Custom endpoint response must include a "version" field.');
181
+ }
182
+
183
+ // Spread the raw payload first so pass-through fields are preserved, then
184
+ // apply the normalized fields last so their defaults are not clobbered by
185
+ // falsy/absent values from the endpoint.
186
+ return {
187
+ ...data,
188
+ version: data.version,
189
+ minVersion: data.minVersion || null,
190
+ releaseNotes: data.releaseNotes || '',
191
+ storeUrl: data.storeUrl || '',
192
+ forceUpdate: data.forceUpdate || false,
193
+ };
194
+ }
195
+
196
+ module.exports = {
197
+ fetchAppStoreVersion,
198
+ fetchPlayStoreVersion,
199
+ fetchCustomEndpoint,
200
+ DEFAULT_TIMEOUT_MS,
201
+ };