@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.
- package/CHANGELOG.md +103 -2
- package/CONTRIBUTING.md +28 -0
- package/README.md +108 -253
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +226 -81
- package/dist/agent/launcher.js +2 -1
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +75 -40
- package/dist/agent/session.js +214 -24
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/runner.js +87 -23
- package/dist/alarms/schedule.js +29 -1
- package/dist/alarms/scheduler.js +11 -3
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +76 -8
- package/dist/alarms/system-volume.js +44 -23
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +182 -199
- package/dist/platform/capabilities.js +8 -1
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +9 -6
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +69 -1
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +7 -1
- package/dist/platform/runtime.js +1 -1
- package/dist/platform/terminals.js +24 -4
- package/dist/player/airplay-discovery.js +144 -61
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +9 -6
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +51 -2
- package/dist/player/player-controller.js +390 -73
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +291 -75
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +272 -41
- package/dist/providers/radio-garden.js +4 -2
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +88 -21
- package/dist/storage/file-lock.js +130 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +314 -76
- package/dist/types.js +22 -1
- package/dist/ui/AdaptiveContent.js +21 -24
- package/dist/ui/App.js +708 -454
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +16 -9
- package/dist/ui/components/ReceiverVisualRow.js +39 -0
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +5 -2
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +10 -4
- package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/HelpScreen.js +3 -3
- package/dist/ui/screens/NowPlayingScreen.js +2 -21
- package/dist/ui/screens/SettingsScreen.js +16 -16
- package/dist/ui/screens/StatsScreen.js +1 -1
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/theme.js +46 -0
- package/dist/ui/use-alarm-tui.js +24 -24
- package/dist/ui/use-app-input.js +42 -8
- package/dist/ui/use-command-executor.js +21 -14
- package/dist/ui/visualizers/boat-lake-kernel.js +325 -0
- package/dist/ui/visualizers/boat-lake.js +161 -0
- package/dist/ui/visualizers/gallop.js +2 -2
- package/dist/ui/visualizers/receiver-style-registry.js +3 -1
- package/dist/ui/visualizers/receiver-visualizers.js +6 -108
- package/dist/ui/visualizers/sumi-ocean.js +183 -0
- package/dist/ui/visualizers/terminal-cell.js +45 -0
- package/dist/update-check.js +2 -1
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/docs/boat-lake.md +88 -0
- package/docs/sumi-ocean.md +80 -0
- package/package.json +36 -12
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
package/dist/providers/cache.js
CHANGED
|
@@ -1,91 +1,287 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
28
|
+
this.options = options;
|
|
14
29
|
}
|
|
15
30
|
get(key, maxAgeMs) {
|
|
16
|
-
const entry = this.
|
|
17
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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.
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
52
|
-
if (
|
|
231
|
+
const path = this.shardPath(key);
|
|
232
|
+
if ((await stat(path)).size > (this.options.maxDiskBytes ?? maxCacheBytes))
|
|
53
233
|
return undefined;
|
|
54
|
-
|
|
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
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
79
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
110
|
-
const
|
|
331
|
+
function writeAtomically(path, serialized) {
|
|
332
|
+
const temp = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
111
333
|
try {
|
|
112
|
-
writeFileSync(
|
|
113
|
-
renameSync(
|
|
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
|
-
|
|
338
|
+
try {
|
|
339
|
+
rmSync(temp, { force: true });
|
|
340
|
+
}
|
|
341
|
+
catch { }
|
|
120
342
|
throw error;
|
|
121
343
|
}
|
|
122
344
|
}
|
|
123
|
-
function backupBadFile(
|
|
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(
|
|
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
|
+
}
|