@ciphore/radiocli 0.2.4 → 0.2.6

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 (145) hide show
  1. package/CHANGELOG.md +103 -2
  2. package/CONTRIBUTING.md +28 -0
  3. package/README.md +108 -253
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +226 -81
  9. package/dist/agent/launcher.js +2 -1
  10. package/dist/agent/mcp-server.js +10 -5
  11. package/dist/agent/service.js +75 -40
  12. package/dist/agent/session.js +214 -24
  13. package/dist/agent/tui-session.js +213 -0
  14. package/dist/alarms/cli.js +19 -3
  15. package/dist/alarms/occurrence-cache.js +25 -0
  16. package/dist/alarms/runner.js +87 -23
  17. package/dist/alarms/schedule.js +29 -1
  18. package/dist/alarms/scheduler.js +11 -3
  19. package/dist/alarms/system-volume-linux.js +160 -0
  20. package/dist/alarms/system-volume-ownership.js +76 -8
  21. package/dist/alarms/system-volume.js +44 -23
  22. package/dist/cli/arguments.js +212 -0
  23. package/dist/cli/completion.js +44 -0
  24. package/dist/cli/doctor.js +156 -0
  25. package/dist/cli/interactive.js +79 -0
  26. package/dist/cli/listener.js +227 -0
  27. package/dist/cli/output.js +121 -0
  28. package/dist/cli/render-tui.js +34 -0
  29. package/dist/cli.js +182 -199
  30. package/dist/platform/capabilities.js +8 -1
  31. package/dist/platform/desktop-media.js +61 -0
  32. package/dist/platform/diagnostics.js +273 -0
  33. package/dist/platform/json-worker.js +111 -0
  34. package/dist/platform/launch-command.js +9 -6
  35. package/dist/platform/mpris.js +356 -0
  36. package/dist/platform/network.js +69 -1
  37. package/dist/platform/omarchy.js +147 -0
  38. package/dist/platform/packages.js +7 -1
  39. package/dist/platform/runtime.js +1 -1
  40. package/dist/platform/terminals.js +24 -4
  41. package/dist/player/airplay-discovery.js +144 -61
  42. package/dist/player/airplay-policy.js +15 -0
  43. package/dist/player/airplay-process.js +63 -0
  44. package/dist/player/airplay-readiness.js +42 -0
  45. package/dist/player/airplay-sender-health.js +49 -49
  46. package/dist/player/airplay-sender-package.js +5 -0
  47. package/dist/player/airplay-sender-patch.js +102 -4
  48. package/dist/player/airplay-setup.js +51 -0
  49. package/dist/player/airplay-worker.js +97 -41
  50. package/dist/player/backend-install.js +9 -6
  51. package/dist/player/ffmpeg-input.js +37 -0
  52. package/dist/player/mpv-desktop-media.js +194 -0
  53. package/dist/player/mpv-ipc-client.js +51 -2
  54. package/dist/player/player-controller.js +390 -73
  55. package/dist/player/radio-engine.js +282 -0
  56. package/dist/playlists/playlist.js +18 -16
  57. package/dist/providers/cache.js +291 -75
  58. package/dist/providers/discovery-session.js +111 -0
  59. package/dist/providers/provider-manager.js +106 -28
  60. package/dist/providers/radio-browser.js +272 -41
  61. package/dist/providers/radio-garden.js +4 -2
  62. package/dist/setup-playback.js +152 -0
  63. package/dist/setup.js +88 -21
  64. package/dist/storage/file-lock.js +130 -0
  65. package/dist/storage/read-settings.js +33 -0
  66. package/dist/storage/replace-file.js +20 -0
  67. package/dist/storage/repository.js +247 -0
  68. package/dist/storage/station-identity.js +21 -0
  69. package/dist/storage/station-schema.js +27 -0
  70. package/dist/storage/store.js +314 -76
  71. package/dist/types.js +22 -1
  72. package/dist/ui/AdaptiveContent.js +21 -24
  73. package/dist/ui/App.js +708 -454
  74. package/dist/ui/AppContent.js +5 -5
  75. package/dist/ui/airplay-settings.js +12 -5
  76. package/dist/ui/bidi-ranges.js +45 -0
  77. package/dist/ui/components/AdaptiveMarquee.js +16 -9
  78. package/dist/ui/components/ReceiverVisualRow.js +39 -0
  79. package/dist/ui/format.js +37 -4
  80. package/dist/ui/help-content.js +5 -2
  81. package/dist/ui/rtl-output.js +137 -0
  82. package/dist/ui/screen-items.js +10 -4
  83. package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
  84. package/dist/ui/screens/AlarmsScreen.js +5 -2
  85. package/dist/ui/screens/HelpScreen.js +3 -3
  86. package/dist/ui/screens/NowPlayingScreen.js +2 -21
  87. package/dist/ui/screens/SettingsScreen.js +16 -16
  88. package/dist/ui/screens/StatsScreen.js +1 -1
  89. package/dist/ui/text-direction.js +9 -0
  90. package/dist/ui/theme.js +46 -0
  91. package/dist/ui/use-alarm-tui.js +24 -24
  92. package/dist/ui/use-app-input.js +42 -8
  93. package/dist/ui/use-command-executor.js +21 -14
  94. package/dist/ui/visualizers/boat-lake-kernel.js +325 -0
  95. package/dist/ui/visualizers/boat-lake.js +161 -0
  96. package/dist/ui/visualizers/gallop.js +2 -2
  97. package/dist/ui/visualizers/receiver-style-registry.js +3 -1
  98. package/dist/ui/visualizers/receiver-visualizers.js +6 -108
  99. package/dist/ui/visualizers/sumi-ocean.js +183 -0
  100. package/dist/ui/visualizers/terminal-cell.js +45 -0
  101. package/dist/update-check.js +2 -1
  102. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  103. package/docs/boat-lake.md +88 -0
  104. package/docs/sumi-ocean.md +80 -0
  105. package/package.json +36 -12
  106. package/scripts/build-airplay-native.mjs +22 -0
  107. package/vendor/node-airtunes2/LICENSE +208 -0
  108. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  109. package/vendor/node-airtunes2/README.md +225 -0
  110. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  111. package/vendor/node-airtunes2/binding.gyp +41 -0
  112. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  113. package/vendor/node-airtunes2/lib/atv.js +307 -0
  114. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  115. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  116. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  117. package/vendor/node-airtunes2/lib/config.js +26 -0
  118. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  119. package/vendor/node-airtunes2/lib/devices.js +266 -0
  120. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  121. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  122. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  123. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  124. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  125. package/vendor/node-airtunes2/lib/http.js +189 -0
  126. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  127. package/vendor/node-airtunes2/lib/index.js +127 -0
  128. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  129. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  130. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  131. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  132. package/vendor/node-airtunes2/lib/srp.js +158 -0
  133. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  134. package/vendor/node-airtunes2/lib/util.js +12 -0
  135. package/vendor/node-airtunes2/package.json +31 -0
  136. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  137. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  138. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  139. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  140. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  141. package/vendor/node-airtunes2/src/base64.c +136 -0
  142. package/vendor/node-airtunes2/src/base64.h +42 -0
  143. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  144. package/vendor/node-airtunes2/src/codec.cc +170 -0
  145. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -1,91 +1,287 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
- import { createHash } from 'node:crypto';
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
3
4
  import { platformPaths } from '../platform/paths.js';
5
+ import { parseJsonAsync, stringifyJsonAsync } from '../platform/json-worker.js';
4
6
  import { join } from 'node:path';
5
7
  const maxCacheEntries = 256;
6
8
  const maxCacheBytes = 128 * 1024 * 1024;
9
+ // Permit small timestamp skew between writers, but never extend freshness by
10
+ // hours or days after a system clock correction.
11
+ const maxFutureClockSkewMs = 1000;
7
12
  export class ProviderCache {
8
13
  filePath;
9
- cache;
10
- writesSincePrune = 0;
11
- constructor(filePath = defaultProviderCachePath()) {
14
+ options;
15
+ cache = { version: 1, entries: Object.create(null) };
16
+ // This set is tied to retained entries; eviction must allow disk reads again.
17
+ checkedShards = new Set();
18
+ entrySizes = new Map();
19
+ memoryBytes = 0;
20
+ legacyAbsent = false;
21
+ legacyLoadPromise = null;
22
+ pendingShardReads = new Map();
23
+ pendingWrites = new Map();
24
+ pendingWriteCount = 0;
25
+ prunePromise = Promise.resolve();
26
+ constructor(filePath = defaultProviderCachePath(), options = {}) {
12
27
  this.filePath = filePath;
13
- this.cache = this.read();
28
+ this.options = options;
14
29
  }
15
30
  get(key, maxAgeMs) {
16
- const entry = this.cache.entries[key] ?? this.readShard(key);
17
- if (!entry) {
18
- return null;
19
- }
20
- if (Date.now() - entry.createdAt > maxAgeMs) {
21
- return null;
22
- }
23
- return entry.value;
31
+ const entry = this.loadEntry(key);
32
+ return entry && withinAge(entry, maxAgeMs) ? entry.value : null;
24
33
  }
25
34
  getStale(key, maxAgeMs = 30 * 24 * 60 * 60 * 1000) {
26
- const entry = this.cache.entries[key] ?? this.readShard(key);
27
- return entry && Date.now() - entry.createdAt <= maxAgeMs ? entry.value : null;
35
+ return this.get(key, maxAgeMs);
36
+ }
37
+ async getAsync(key, maxAgeMs) {
38
+ // A caller reading during a refresh observes the completed newest write.
39
+ await this.pendingWrites.get(key)?.catch(() => undefined);
40
+ const entry = this.memoryEntry(key) ?? await this.loadEntryAsync(key);
41
+ return entry && withinAge(entry, maxAgeMs) ? entry.value : null;
42
+ }
43
+ getStaleAsync(key, maxAgeMs = 30 * 24 * 60 * 60 * 1000) {
44
+ return this.getAsync(key, maxAgeMs);
28
45
  }
29
46
  set(key, value) {
30
- this.cache.entries[key] = { createdAt: Date.now(), value };
31
- this.writeEntry(key, this.cache.entries[key]);
47
+ if (this.pendingWrites.has(key))
48
+ throw new Error('Provider cache is refreshing this entry. Use setAsync to keep writes ordered.');
49
+ const read = this.pendingShardReads.get(key);
50
+ if (read)
51
+ read.invalidated = true;
52
+ const entry = { createdAt: Date.now(), value };
53
+ const serialized = JSON.stringify({ version: 1, key, ...entry });
54
+ this.remember(key, entry, retainedBytes(serialized));
55
+ const directory = this.shardDirectory();
56
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
57
+ writeAtomically(this.shardPath(key), serialized);
58
+ this.pruneShards(directory);
59
+ }
60
+ async setAsync(key, value) {
61
+ if (this.pendingWriteCount >= (this.options.maxPendingOperations ?? 16))
62
+ throw new Error('Provider cache write capacity reached.');
63
+ const entry = { createdAt: Date.now(), value };
64
+ const read = this.pendingShardReads.get(key);
65
+ if (read)
66
+ read.invalidated = true;
67
+ this.pendingWriteCount += 1;
68
+ const previous = this.pendingWrites.get(key) ?? Promise.resolve();
69
+ const pending = previous.catch(() => undefined).then(async () => {
70
+ const serialized = await stringifyJsonAsync({ version: 1, key, ...entry }, isLargeValue(value));
71
+ this.remember(key, entry, retainedBytes(serialized));
72
+ const directory = this.shardDirectory();
73
+ await mkdir(directory, { recursive: true, mode: 0o700 });
74
+ const path = this.shardPath(key);
75
+ const tempPath = `${path}.tmp-${process.pid}-${randomUUID()}`;
76
+ try {
77
+ await writeFile(tempPath, `${serialized}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
78
+ await rename(tempPath, path);
79
+ }
80
+ catch (error) {
81
+ await rm(tempPath, { force: true }).catch(() => undefined);
82
+ throw error;
83
+ }
84
+ const prune = this.prunePromise.catch(() => undefined).then(() => this.pruneShardsAsync(directory));
85
+ this.prunePromise = prune;
86
+ await prune;
87
+ });
88
+ this.pendingWrites.set(key, pending);
89
+ try {
90
+ await pending;
91
+ }
92
+ finally {
93
+ this.pendingWriteCount -= 1;
94
+ if (this.pendingWrites.get(key) === pending)
95
+ this.pendingWrites.delete(key);
96
+ }
97
+ }
98
+ memoryEntry(key) {
99
+ const entry = this.cache.entries[key];
100
+ if (entry) {
101
+ const bytes = this.entrySizes.get(key);
102
+ this.entrySizes.delete(key);
103
+ this.entrySizes.set(key, bytes);
104
+ }
105
+ return entry;
32
106
  }
33
- read() {
34
- if (!existsSync(this.filePath)) {
35
- return { version: 1, entries: {} };
107
+ remember(key, entry, bytes = retainedBytes(JSON.stringify(entry))) {
108
+ this.forget(key);
109
+ if (bytes > (this.options.maxMemoryBytes ?? 64 * 1024 * 1024))
110
+ return;
111
+ this.cache.entries[key] = entry;
112
+ this.entrySizes.set(key, bytes);
113
+ this.checkedShards.add(key);
114
+ this.memoryBytes += bytes;
115
+ while (this.entrySizes.size > (this.options.maxMemoryEntries ?? maxCacheEntries) || this.memoryBytes > (this.options.maxMemoryBytes ?? 64 * 1024 * 1024)) {
116
+ const oldest = this.entrySizes.keys().next().value;
117
+ if (oldest === undefined)
118
+ break;
119
+ this.forget(oldest);
36
120
  }
121
+ }
122
+ forget(key) {
123
+ this.memoryBytes -= this.entrySizes.get(key) ?? 0;
124
+ this.entrySizes.delete(key);
125
+ this.checkedShards.delete(key);
126
+ delete this.cache.entries[key];
127
+ }
128
+ loadEntry(key) {
129
+ const memory = this.memoryEntry(key);
130
+ if (memory)
131
+ return memory;
132
+ const legacy = this.readLegacy().entries[key];
133
+ const shard = this.readShard(key);
134
+ const entry = newerEntry(legacy, shard);
135
+ if (entry)
136
+ this.remember(key, entry);
137
+ return entry;
138
+ }
139
+ async loadEntryAsync(key) {
140
+ const pending = this.pendingShardReads.get(key);
141
+ if (pending)
142
+ return pending.promise;
143
+ // Cache misses can proceed to the directory instead of growing an unbounded
144
+ // queue of disk reads. No negative entry prevents a later shard discovery.
145
+ if (this.pendingShardReads.size >= (this.options.maxPendingOperations ?? 16))
146
+ return undefined;
147
+ const record = { invalidated: false, promise: Promise.resolve(undefined) };
148
+ const work = async () => {
149
+ do {
150
+ record.invalidated = false;
151
+ const [legacy, shard] = await Promise.all([this.readLegacyAsync(), this.readShardAsync(key)]);
152
+ if (!record.invalidated) {
153
+ const entry = newerEntry(legacy.entries[key], shard);
154
+ if (entry) {
155
+ const serialized = await stringifyJsonAsync(entry, isLargeValue(entry.value));
156
+ if (record.invalidated) {
157
+ await this.pendingWrites.get(key)?.catch(() => undefined);
158
+ const current = this.memoryEntry(key);
159
+ if (current)
160
+ return current;
161
+ continue;
162
+ }
163
+ this.remember(key, entry, retainedBytes(serialized));
164
+ }
165
+ return entry;
166
+ }
167
+ await this.pendingWrites.get(key)?.catch(() => undefined);
168
+ const current = this.memoryEntry(key);
169
+ if (current)
170
+ return current;
171
+ } while (true);
172
+ };
173
+ record.promise = work().finally(() => { if (this.pendingShardReads.get(key) === record)
174
+ this.pendingShardReads.delete(key); });
175
+ this.pendingShardReads.set(key, record);
176
+ return record.promise;
177
+ }
178
+ readLegacy() {
179
+ if (this.legacyAbsent)
180
+ return emptyEnvelope();
37
181
  try {
38
- const parsed = JSON.parse(readFileSync(this.filePath, 'utf8'));
39
- return parsed.version === 1 && parsed.entries ? parsed : { version: 1, entries: {} };
182
+ if (statSync(this.filePath).size > (this.options.maxDiskBytes ?? maxCacheBytes))
183
+ return emptyEnvelope();
184
+ return validEnvelope(JSON.parse(readFileSync(this.filePath, 'utf8')));
40
185
  }
41
- catch {
42
- backupBadFile(this.filePath);
43
- return { version: 1, entries: {} };
186
+ catch (error) {
187
+ if (error.code === 'ENOENT')
188
+ this.legacyAbsent = true;
189
+ else if (error instanceof SyntaxError)
190
+ backupBadFile(this.filePath);
191
+ return emptyEnvelope();
44
192
  }
45
193
  }
194
+ readLegacyAsync() {
195
+ if (this.legacyAbsent)
196
+ return Promise.resolve(emptyEnvelope());
197
+ if (this.legacyLoadPromise)
198
+ return this.legacyLoadPromise;
199
+ const reading = (async () => {
200
+ try {
201
+ if ((await stat(this.filePath)).size > (this.options.maxDiskBytes ?? maxCacheBytes))
202
+ return emptyEnvelope();
203
+ return validEnvelope(await parseJsonAsync(await readFile(this.filePath, 'utf8')));
204
+ }
205
+ catch (error) {
206
+ if (error.code === 'ENOENT')
207
+ this.legacyAbsent = true;
208
+ else if (error instanceof SyntaxError)
209
+ await rename(this.filePath, `${this.filePath}.bad-${Date.now()}-${randomUUID()}`).catch(() => undefined);
210
+ return emptyEnvelope();
211
+ }
212
+ })();
213
+ const tracked = reading.finally(() => { if (this.legacyLoadPromise === tracked)
214
+ this.legacyLoadPromise = null; });
215
+ this.legacyLoadPromise = tracked;
216
+ return tracked;
217
+ }
46
218
  readShard(key) {
47
- const path = join(this.shardDirectory(), `${createHash('sha256').update(key).digest('hex')}.json`);
48
- if (!existsSync(path))
219
+ try {
220
+ const path = this.shardPath(key);
221
+ if (statSync(path).size > (this.options.maxDiskBytes ?? maxCacheBytes))
222
+ return undefined;
223
+ return validShard(JSON.parse(readFileSync(path, 'utf8')), key);
224
+ }
225
+ catch {
49
226
  return undefined;
227
+ }
228
+ }
229
+ async readShardAsync(key) {
50
230
  try {
51
- const entry = JSON.parse(readFileSync(path, 'utf8'));
52
- if (entry.version !== 1 || entry.key !== key || !Number.isFinite(entry.createdAt))
231
+ const path = this.shardPath(key);
232
+ if ((await stat(path)).size > (this.options.maxDiskBytes ?? maxCacheBytes))
53
233
  return undefined;
54
- const cached = { createdAt: entry.createdAt, value: entry.value };
55
- this.cache.entries[key] = cached;
56
- return cached;
234
+ return validShard(await parseJsonAsync(await readFile(path, 'utf8')), key);
57
235
  }
58
236
  catch {
59
237
  return undefined;
60
238
  }
61
239
  }
62
- writeEntry(key, entry) {
63
- const directory = this.shardDirectory();
64
- mkdirSync(directory, { recursive: true, mode: 0o700 });
65
- const path = join(directory, `${createHash('sha256').update(key).digest('hex')}.json`);
66
- writeJsonAtomically(path, { version: 1, key, ...entry });
67
- this.writesSincePrune += 1;
68
- if (this.writesSincePrune >= 16) {
69
- this.writesSincePrune = 0;
70
- this.pruneShards(directory);
240
+ async pruneShardsAsync(directory) {
241
+ const names = (await readdir(directory)).filter(name => name.endsWith('.json'));
242
+ const files = [];
243
+ for (let index = 0; index < names.length; index += 32) {
244
+ const entries = await Promise.all(names.slice(index, index + 32).map(async (name) => {
245
+ const path = join(directory, name);
246
+ try {
247
+ const info = await stat(path);
248
+ return { path, bytes: info.size, modified: info.mtimeMs, inode: info.ino };
249
+ }
250
+ catch {
251
+ return null;
252
+ }
253
+ }));
254
+ files.push(...entries.filter((entry) => entry !== null));
255
+ }
256
+ files.sort((left, right) => right.modified - left.modified);
257
+ let totalBytes = files.reduce((sum, file) => sum + file.bytes, 0);
258
+ while (files.length > (this.options.maxDiskEntries ?? maxCacheEntries) || totalBytes > (this.options.maxDiskBytes ?? maxCacheBytes)) {
259
+ const file = files.pop();
260
+ if (!file)
261
+ break;
262
+ try {
263
+ const current = await stat(file.path);
264
+ if (current.ino !== file.inode || current.mtimeMs !== file.modified || current.size !== file.bytes)
265
+ continue;
266
+ await unlink(file.path);
267
+ totalBytes -= file.bytes;
268
+ }
269
+ catch { /* Another process may have refreshed or evicted the shard. */ }
71
270
  }
72
271
  }
73
272
  pruneShards(directory) {
74
- const files = readdirSync(directory)
75
- .filter(name => name.endsWith('.json'))
76
- .flatMap(name => {
273
+ const files = readdirSync(directory).filter(name => name.endsWith('.json')).flatMap(name => {
274
+ const path = join(directory, name);
77
275
  try {
78
- const path = join(directory, name);
79
- const stat = statSync(path);
80
- return [{ path, bytes: stat.size, modified: stat.mtimeMs }];
276
+ const info = statSync(path);
277
+ return [{ path, bytes: info.size, modified: info.mtimeMs }];
81
278
  }
82
279
  catch {
83
280
  return [];
84
281
  }
85
- })
86
- .sort((left, right) => right.modified - left.modified);
87
- let totalBytes = files.reduce((total, file) => total + file.bytes, 0);
88
- while (files.length > maxCacheEntries || totalBytes > maxCacheBytes) {
282
+ }).sort((left, right) => right.modified - left.modified);
283
+ let totalBytes = files.reduce((sum, file) => sum + file.bytes, 0);
284
+ while (files.length > (this.options.maxDiskEntries ?? maxCacheEntries) || totalBytes > (this.options.maxDiskBytes ?? maxCacheBytes)) {
89
285
  const file = files.pop();
90
286
  if (!file)
91
287
  break;
@@ -93,42 +289,62 @@ export class ProviderCache {
93
289
  unlinkSync(file.path);
94
290
  totalBytes -= file.bytes;
95
291
  }
96
- catch {
97
- // Cache eviction is best-effort.
98
- }
292
+ catch { /* Cache eviction is best-effort. */ }
99
293
  }
100
294
  }
101
- shardDirectory() {
102
- return `${this.filePath}.d`;
103
- }
295
+ shardDirectory() { return `${this.filePath}.d`; }
296
+ shardPath(key) { return join(this.shardDirectory(), `${createHash('sha256').update(key).digest('hex')}.json`); }
297
+ }
298
+ function retainedBytes(serialized) { return serialized.length * 2 + 128; }
299
+ function isLargeValue(value) { return Array.isArray(value) && value.length >= 1000 || typeof value === 'string' && value.length >= 256 * 1024; }
300
+ function emptyEnvelope() { return { version: 1, entries: Object.create(null) }; }
301
+ function validEnvelope(value) {
302
+ const envelope = value;
303
+ if (envelope?.version !== 1 || !envelope.entries || typeof envelope.entries !== 'object' || Array.isArray(envelope.entries))
304
+ return emptyEnvelope();
305
+ const entries = Object.create(null);
306
+ for (const [key, entry] of Object.entries(envelope.entries))
307
+ if (entry && Number.isFinite(entry.createdAt) && 'value' in entry)
308
+ entries[key] = entry;
309
+ return { version: 1, entries };
310
+ }
311
+ function validShard(value, key) {
312
+ const entry = value;
313
+ return entry?.version === 1 && entry.key === key && Number.isFinite(entry.createdAt) && 'value' in entry ? { createdAt: entry.createdAt, value: entry.value } : undefined;
314
+ }
315
+ function newerEntry(legacy, shard) {
316
+ const now = Date.now();
317
+ if (legacy && !withinAge(legacy, Infinity, now))
318
+ legacy = undefined;
319
+ if (shard && !withinAge(shard, Infinity, now))
320
+ shard = undefined;
321
+ return shard && (!legacy || shard.createdAt >= legacy.createdAt) ? shard : legacy;
322
+ }
323
+ function withinAge(entry, maxAgeMs, now = Date.now()) {
324
+ const age = now - entry.createdAt;
325
+ return age >= -maxFutureClockSkewMs && Math.max(0, age) <= maxAgeMs;
104
326
  }
105
327
  function defaultProviderCachePath() {
106
328
  const { cache, legacyCache } = platformPaths();
107
329
  return existsSync(cache) || !existsSync(legacyCache) ? cache : legacyCache;
108
330
  }
109
- function writeJsonAtomically(filePath, value) {
110
- const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
331
+ function writeAtomically(path, serialized) {
332
+ const temp = `${path}.tmp-${process.pid}-${randomUUID()}`;
111
333
  try {
112
- writeFileSync(tempPath, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600 });
113
- renameSync(tempPath, filePath);
114
- if (process.platform !== 'win32') {
115
- chmodSync(filePath, 0o600);
116
- }
334
+ writeFileSync(temp, `${serialized}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
335
+ renameSync(temp, path);
117
336
  }
118
337
  catch (error) {
119
- rmSync(tempPath, { force: true });
338
+ try {
339
+ rmSync(temp, { force: true });
340
+ }
341
+ catch { }
120
342
  throw error;
121
343
  }
122
344
  }
123
- function backupBadFile(filePath) {
124
- if (!existsSync(filePath)) {
125
- return;
126
- }
127
- const backupPath = `${filePath}.bad-${new Date().toISOString().replace(/[:.]/g, '-')}`;
345
+ function backupBadFile(path) {
128
346
  try {
129
- renameSync(filePath, backupPath);
130
- }
131
- catch {
132
- // If backup fails, leave the original in place and continue with defaults in memory.
347
+ renameSync(path, `${path}.bad-${Date.now()}-${randomUUID()}`);
133
348
  }
349
+ catch { /* Cache data is recoverable. */ }
134
350
  }
@@ -0,0 +1,111 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /** Cursor state is private, bounded, and committed only after a complete page.
3
+ * One-page replay makes retries safe without retaining every returned station. */
4
+ export class DiscoverySessions {
5
+ sizeOf;
6
+ options;
7
+ sessions = new Map();
8
+ constructor(sizeOf, options = {}) {
9
+ this.sizeOf = sizeOf;
10
+ this.options = options;
11
+ }
12
+ async page(fingerprint, cursor, create, produce, signal) {
13
+ signal?.throwIfAborted();
14
+ const now = this.options.now?.() ?? Date.now();
15
+ this.prune(now);
16
+ let session;
17
+ let index = 0;
18
+ if (cursor) {
19
+ const match = /^([a-f0-9-]{36}):(\d+)$/.exec(cursor);
20
+ const found = match ? this.sessions.get(match[1]) : undefined;
21
+ index = Number(match?.[2]);
22
+ if (!found || !Number.isSafeInteger(index))
23
+ throw cursorError();
24
+ if (found.fingerprint !== fingerprint)
25
+ throw new Error('Discovery cursor does not match this query or its filters. Start the search again.');
26
+ session = found;
27
+ }
28
+ else {
29
+ this.makeRoom(1, 0);
30
+ const state = create();
31
+ session = { id: randomUUID(), fingerprint, state, nextPage: 0, touchedAt: now, bytes: this.sizeOf(state) };
32
+ this.sessions.set(session.id, session);
33
+ }
34
+ session.touchedAt = now;
35
+ if (session.last?.index === index)
36
+ return structuredClone(session.last.page);
37
+ if (session.pending?.index === index)
38
+ return structuredClone(await abortable(session.pending.promise, signal));
39
+ if (index !== session.nextPage || session.pending)
40
+ throw cursorError();
41
+ // Keep offsets and overflow intact if a request fails or its caller cancels.
42
+ const draft = structuredClone(session.state);
43
+ const work = (async () => {
44
+ const page = await produce(draft);
45
+ signal?.throwIfAborted();
46
+ const bytes = this.sizeOf(draft) + JSON.stringify(page).length * 2;
47
+ this.makeRoom(0, bytes - session.bytes, session.id);
48
+ const result = {
49
+ ...page,
50
+ nextCursor: page.exhausted ? undefined : `${session.id}:${index + 1}`
51
+ };
52
+ session.state = draft;
53
+ session.bytes = bytes;
54
+ session.nextPage = index + 1;
55
+ session.last = { index, page: result };
56
+ return result;
57
+ })();
58
+ session.pending = { index, promise: work };
59
+ try {
60
+ return structuredClone(await work);
61
+ }
62
+ catch (error) {
63
+ if (session.nextPage === 0)
64
+ this.sessions.delete(session.id);
65
+ throw error;
66
+ }
67
+ finally {
68
+ if (session.pending?.promise === work)
69
+ session.pending = undefined;
70
+ }
71
+ }
72
+ prune(now) {
73
+ const ttl = this.options.ttlMs ?? 10 * 60_000;
74
+ for (const [id, session] of this.sessions) {
75
+ if (!session.pending && (now < session.touchedAt || now - session.touchedAt >= ttl))
76
+ this.sessions.delete(id);
77
+ }
78
+ }
79
+ makeRoom(additionalCount, additionalBytes, protectedId) {
80
+ const maxSessions = this.options.maxSessions ?? 16;
81
+ const maxBytes = this.options.maxBytes ?? 32 * 1024 * 1024;
82
+ let bytes = [...this.sessions.values()].reduce((sum, entry) => sum + entry.bytes, 0) + additionalBytes;
83
+ const candidates = [...this.sessions.values()]
84
+ .filter(entry => entry.id !== protectedId && !entry.pending)
85
+ .sort((left, right) => left.touchedAt - right.touchedAt);
86
+ while (this.sessions.size + additionalCount > maxSessions || bytes > maxBytes) {
87
+ const oldest = candidates.shift();
88
+ if (!oldest)
89
+ throw new Error('Discovery session capacity reached. Finish another search or start a narrower query.');
90
+ this.sessions.delete(oldest.id);
91
+ bytes -= oldest.bytes;
92
+ }
93
+ }
94
+ }
95
+ /** Abort the wait even when an injected provider or DNS resolver ignores signal. */
96
+ export function abortable(work, signal) {
97
+ if (!signal)
98
+ return work;
99
+ if (signal.aborted) {
100
+ void work.catch(() => undefined);
101
+ return Promise.reject(signal.reason);
102
+ }
103
+ return new Promise((resolve, reject) => {
104
+ const aborted = () => reject(signal.reason);
105
+ signal.addEventListener('abort', aborted, { once: true });
106
+ work.then(resolve, reject).finally(() => signal.removeEventListener('abort', aborted));
107
+ });
108
+ }
109
+ function cursorError() {
110
+ return new Error('Discovery cursor expired or is no longer available. Start the search again.');
111
+ }