@cortexkit/common-auth 0.11.3 → 0.11.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/rpc/port-file.d.ts +2 -0
- package/dist/rpc/port-file.js +14 -1
- package/dist/rpc/rpc-client.d.ts +8 -3
- package/dist/rpc/rpc-client.js +111 -11
- package/dist/tui-prefs/watcher.d.ts +18 -2
- package/dist/tui-prefs/watcher.js +113 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,14 +11,14 @@ Shared libraries for the CortexKit auth plugins for OpenCode and Pi: openai-auth
|
|
|
11
11
|
|
|
12
12
|
A passing safety test proves little until it has been seen to fail. [`mutations.toml`](mutations.toml) is the checked-in catalogue: each row deliberately breaks one guard in `src/` (or a CI scan script) and names the Bun test that must fail because of it, so a guard cannot silently stop guarding. A row earns its place by guarding a silent, costly failure: lock exclusion, crash safety, credential loss or leakage, a single-use token used twice, misattributed quota, a wire contract, data loss. Style and cosmetics do not.
|
|
13
13
|
|
|
14
|
-
The pinned [`
|
|
14
|
+
The pinned [`ckdev-mutate`](https://github.com/cortexkit/commons/tree/46cc166b0df2edcfd14b3eb54ed6eeac588fed69/crates/cortexkit-mutate) runner replays it from a clean tree. It installs and builds first (fixtures import `dist/`), checks each named test passes, applies the break, rebuilds, requires the test to fail, and restores the source byte for byte:
|
|
15
15
|
|
|
16
16
|
```sh
|
|
17
|
-
cargo install --locked --git https://github.com/cortexkit/commons --rev
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
cargo install --locked --git https://github.com/cortexkit/commons --rev 46cc166b0df2edcfd14b3eb54ed6eeac588fed69 cortexkit-mutate
|
|
18
|
+
ckdev-mutate check
|
|
19
|
+
ckdev-mutate run --all
|
|
20
|
+
ckdev-mutate run --diff origin/main
|
|
21
|
+
ckdev-mutate run --only fs-lock-try-once-refuses-held-lock
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
Every row must report CAUGHT. Replay with the Bun version CI pins (1.3.14): the row `rpc-declared-oversize-413-half-closes` guards the RPC server's workaround for Bun 1.3.14 reusing a connection the server refused with 413. Bun 1.4.2 never reuses it, so that row survives there. Rows are command rows: `{test}` is a Bun `-t` filter, a regular expression over the full test name (describe names and test name joined by spaces), so ids are anchored, escaped, and spell each space `\s`. CI replays the rows a pull request's diff selects, the full catalogue on every push to main, and the full catalogue with `--broad` nightly. Never `git checkout` a file while a replay runs: it removes the mutation and fakes a survivor.
|
package/dist/rpc/port-file.d.ts
CHANGED
|
@@ -28,4 +28,6 @@ export interface DiscoverPortFileOptions {
|
|
|
28
28
|
*/
|
|
29
29
|
exactPid?: boolean;
|
|
30
30
|
}
|
|
31
|
+
/** Internal cache validation: probe only the selected file and its PID. */
|
|
32
|
+
export declare function portFileIdentity(dir: string, entry: PortFileEntry): Promise<string | null>;
|
|
31
33
|
export declare function discoverPortFile(dir: string, expectedPid?: number, options?: DiscoverPortFileOptions): Promise<PortFileEntry | null>;
|
package/dist/rpc/port-file.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { chmod, mkdir, open, readdir, readFile, rename, rmdir, unlink, } from 'node:fs/promises';
|
|
2
|
+
import { chmod, mkdir, open, readdir, readFile, rename, rmdir, stat, unlink, } from 'node:fs/promises';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
/**
|
|
5
5
|
* A PID a liveness probe may address. `process.kill(0 | negative, 0)` signals
|
|
@@ -166,6 +166,19 @@ export async function sweepRpcState(root, activeDir, isManagedDir, log) {
|
|
|
166
166
|
await rmdir(dir).catch(() => { });
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
|
+
/** Internal cache validation: probe only the selected file and its PID. */
|
|
170
|
+
export async function portFileIdentity(dir, entry) {
|
|
171
|
+
if (!pidAlive(entry.pid))
|
|
172
|
+
return null;
|
|
173
|
+
const path = join(resolve(dir), `port-${entry.pid}.json`);
|
|
174
|
+
try {
|
|
175
|
+
const info = await stat(path);
|
|
176
|
+
return `${path}:${info.dev}:${info.ino}:${info.mtimeMs}:${info.size}`;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
169
182
|
export async function discoverPortFile(dir, expectedPid, options = {}) {
|
|
170
183
|
let names;
|
|
171
184
|
try {
|
package/dist/rpc/rpc-client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ApplyRequest, ApplyResult, RpcNotification } from './notifications.js';
|
|
2
|
-
import { type DiscoverPortFileOptions, type PortFileEntry } from './port-file.js';
|
|
2
|
+
import { type DiscoverPortFileOptions, discoverPortFile, type PortFileEntry } from './port-file.js';
|
|
3
3
|
export interface RpcClient {
|
|
4
4
|
pending: (lastReceivedId: number, sessionId?: string, timeoutMs?: number) => Promise<RpcNotification[]>;
|
|
5
5
|
apply: (request: ApplyRequest, timeoutMs?: number) => Promise<ApplyResult>;
|
|
@@ -11,7 +11,10 @@ export declare const DEFAULT_RPC_TIMEOUT_MS = 2000;
|
|
|
11
11
|
* opening a socket, on every call. Off by default, when a call falls back to
|
|
12
12
|
* the newest live server.
|
|
13
13
|
*/
|
|
14
|
-
export type RpcClientOptions = DiscoverPortFileOptions
|
|
14
|
+
export type RpcClientOptions = DiscoverPortFileOptions & {
|
|
15
|
+
/** Internal discovery-count seam. */
|
|
16
|
+
discover?: typeof discoverPortFile;
|
|
17
|
+
};
|
|
15
18
|
/**
|
|
16
19
|
* A client for the server in `dir`, preferring `expectedPid`'s.
|
|
17
20
|
*
|
|
@@ -20,6 +23,8 @@ export type RpcClientOptions = DiscoverPortFileOptions;
|
|
|
20
23
|
* a gate: an observer that throws rejects that call before any request is
|
|
21
24
|
* sent and is asked again on the next call, but nothing stops a later call
|
|
22
25
|
* once an observer has returned. A caller that must never reach another
|
|
23
|
-
* server passes `{ exactPid: true }`.
|
|
26
|
+
* server passes `{ exactPid: true }`. Validated selections are shared by
|
|
27
|
+
* directory, expected PID and exactPid until their file identity changes or a
|
|
28
|
+
* connect/auth/stale-server failure triggers one rediscovery in the same call.
|
|
24
29
|
*/
|
|
25
30
|
export declare function createRpcClient(dir: string, expectedPid?: number, onSelected?: (entry: PortFileEntry | null) => void, options?: RpcClientOptions): RpcClient;
|
package/dist/rpc/rpc-client.js
CHANGED
|
@@ -1,30 +1,87 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
1
2
|
import { connect } from 'node:net';
|
|
2
|
-
import {
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { discoverPortFile, portFileIdentity, } from './port-file.js';
|
|
3
5
|
export const DEFAULT_RPC_TIMEOUT_MS = 2_000;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
const selections = new Map();
|
|
7
|
+
const discoveries = new Map();
|
|
8
|
+
// The identity a cached selection is checked against must belong to the bytes
|
|
9
|
+
// that named this server. Statting the path after discovery is not enough: the
|
|
10
|
+
// file can be replaced in between, which would pin the old server to the new
|
|
11
|
+
// file's identity. So read and fstat one open descriptor, and bind only when
|
|
12
|
+
// those bytes still name the discovered entry.
|
|
13
|
+
async function boundIdentity(dir, entry) {
|
|
14
|
+
const path = join(resolve(dir), `port-${entry.pid}.json`);
|
|
15
|
+
let handle;
|
|
16
|
+
try {
|
|
17
|
+
handle = await open(path, 'r');
|
|
18
|
+
const info = await handle.stat();
|
|
19
|
+
const current = JSON.parse(await handle.readFile('utf8'));
|
|
20
|
+
if (current.pid !== entry.pid ||
|
|
21
|
+
current.port !== entry.port ||
|
|
22
|
+
current.token !== entry.token)
|
|
23
|
+
return null;
|
|
24
|
+
return `${path}:${info.dev}:${info.ino}:${info.mtimeMs}:${info.size}`;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
8
27
|
return null;
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
await handle?.close().catch(() => { });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function select(key, dir, expectedPid, options) {
|
|
34
|
+
const cached = selections.get(key);
|
|
35
|
+
if (cached && (await portFileIdentity(dir, cached.entry)) === cached.identity)
|
|
36
|
+
return cached;
|
|
37
|
+
selections.delete(key);
|
|
38
|
+
const pending = discoveries.get(key);
|
|
39
|
+
if (pending)
|
|
40
|
+
return pending;
|
|
41
|
+
const discovery = (async () => {
|
|
42
|
+
const entry = await (options.discover ?? discoverPortFile)(dir, expectedPid, options);
|
|
43
|
+
if (!entry)
|
|
44
|
+
return null;
|
|
45
|
+
const identity = await boundIdentity(dir, entry);
|
|
46
|
+
// The file changed under discovery: use what was discovered for this
|
|
47
|
+
// call, as an uncached selection, so the next call discovers again.
|
|
48
|
+
if (!identity)
|
|
49
|
+
return { entry, identity: '' };
|
|
50
|
+
const selected = { entry, identity };
|
|
51
|
+
selections.set(key, selected);
|
|
52
|
+
return selected;
|
|
53
|
+
})();
|
|
54
|
+
discoveries.set(key, discovery);
|
|
55
|
+
try {
|
|
56
|
+
return await discovery;
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
if (discoveries.get(key) === discovery)
|
|
60
|
+
discoveries.delete(key);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function request(entry, method, params, timeoutMs = DEFAULT_RPC_TIMEOUT_MS) {
|
|
9
64
|
// A raw loopback socket never consults runtime HTTP proxy settings, which
|
|
10
65
|
// otherwise can expose the bearer token to a configured proxy. timeoutMs is
|
|
11
66
|
// a total deadline for connect, request and the full response, not idle time.
|
|
12
67
|
return new Promise((resolve) => {
|
|
13
68
|
let socket;
|
|
14
69
|
let settled = false;
|
|
15
|
-
|
|
70
|
+
let connected = false;
|
|
71
|
+
const done = (value, stale = false) => {
|
|
16
72
|
if (settled)
|
|
17
73
|
return;
|
|
18
74
|
settled = true;
|
|
19
75
|
clearTimeout(timer);
|
|
20
76
|
socket?.destroy();
|
|
21
|
-
resolve(value);
|
|
77
|
+
resolve({ value, stale });
|
|
22
78
|
};
|
|
23
79
|
const timer = setTimeout(() => done(null), timeoutMs);
|
|
24
80
|
try {
|
|
25
81
|
const body = JSON.stringify(params);
|
|
26
82
|
socket = connect({ host: '127.0.0.1', port: entry.port });
|
|
27
83
|
socket.on('connect', () => {
|
|
84
|
+
connected = true;
|
|
28
85
|
// HTTP/1.0 avoids chunked responses; explicitly request connection close
|
|
29
86
|
// because older Bun servers can keep delayed HTTP/1.0 replies open.
|
|
30
87
|
socket?.write(`POST /rpc/${method} HTTP/1.0\r\n` +
|
|
@@ -69,8 +126,15 @@ async function call(dir, expectedPid, discoverOptions, onSelected, method, param
|
|
|
69
126
|
.toString('latin1')
|
|
70
127
|
.split('\r\n');
|
|
71
128
|
const status = /^HTTP\/1\.[01] (\d{3})(?: |$)/.exec(headers[0] ?? '');
|
|
72
|
-
if (!status
|
|
129
|
+
if (!status)
|
|
73
130
|
return done(null);
|
|
131
|
+
const code = Number(status[1]);
|
|
132
|
+
if (code < 200 || code >= 300)
|
|
133
|
+
// Only statuses meaning "this is not the server you meant" may
|
|
134
|
+
// rediscover and resend. A 504 is this server's apply deadline:
|
|
135
|
+
// the handler can still be running, so resending it to another
|
|
136
|
+
// server could run the command twice. 5xx is never retried.
|
|
137
|
+
return done(null, [401, 403, 404, 410].includes(code));
|
|
74
138
|
for (const header of headers.slice(1)) {
|
|
75
139
|
const colon = header.indexOf(':');
|
|
76
140
|
const name = header.slice(0, colon).toLowerCase();
|
|
@@ -107,14 +171,47 @@ async function call(dir, expectedPid, discoverOptions, onSelected, method, param
|
|
|
107
171
|
return done(null);
|
|
108
172
|
finishBody();
|
|
109
173
|
});
|
|
110
|
-
socket.on('error', () => done(null));
|
|
174
|
+
socket.on('error', () => done(null, !connected));
|
|
111
175
|
socket.on('close', () => done(null));
|
|
112
176
|
}
|
|
113
177
|
catch {
|
|
114
|
-
done(null);
|
|
178
|
+
done(null, !connected);
|
|
115
179
|
}
|
|
116
180
|
});
|
|
117
181
|
}
|
|
182
|
+
async function call(dir, expectedPid, options, onSelected, method, params, timeoutMs = DEFAULT_RPC_TIMEOUT_MS) {
|
|
183
|
+
const key = JSON.stringify([
|
|
184
|
+
resolve(dir),
|
|
185
|
+
expectedPid ?? null,
|
|
186
|
+
options.exactPid === true,
|
|
187
|
+
]);
|
|
188
|
+
const selected = await select(key, dir, expectedPid, options);
|
|
189
|
+
onSelected(selected?.entry ?? null);
|
|
190
|
+
if (!selected)
|
|
191
|
+
return null;
|
|
192
|
+
const deadline = Date.now() + timeoutMs;
|
|
193
|
+
const out = await request(selected.entry, method, params, timeoutMs);
|
|
194
|
+
if (!out.stale)
|
|
195
|
+
return out.value;
|
|
196
|
+
if (selections.get(key) === selected)
|
|
197
|
+
selections.delete(key);
|
|
198
|
+
// Retry only definite pre-dispatch/stale-server failures, never an ambiguous
|
|
199
|
+
// response or timeout that could replay an already executed apply command.
|
|
200
|
+
const replacement = await select(key, dir, expectedPid, options);
|
|
201
|
+
if (!replacement || Date.now() >= deadline)
|
|
202
|
+
return null;
|
|
203
|
+
if (replacement.entry.port === selected.entry.port &&
|
|
204
|
+
replacement.entry.pid === selected.entry.pid &&
|
|
205
|
+
replacement.entry.token === selected.entry.token) {
|
|
206
|
+
if (selections.get(key) === replacement)
|
|
207
|
+
selections.delete(key);
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
const retry = await request(replacement.entry, method, params, deadline - Date.now());
|
|
211
|
+
if (retry.stale && selections.get(key) === replacement)
|
|
212
|
+
selections.delete(key);
|
|
213
|
+
return retry.value;
|
|
214
|
+
}
|
|
118
215
|
/**
|
|
119
216
|
* A client for the server in `dir`, preferring `expectedPid`'s.
|
|
120
217
|
*
|
|
@@ -123,11 +220,14 @@ async function call(dir, expectedPid, discoverOptions, onSelected, method, param
|
|
|
123
220
|
* a gate: an observer that throws rejects that call before any request is
|
|
124
221
|
* sent and is asked again on the next call, but nothing stops a later call
|
|
125
222
|
* once an observer has returned. A caller that must never reach another
|
|
126
|
-
* server passes `{ exactPid: true }`.
|
|
223
|
+
* server passes `{ exactPid: true }`. Validated selections are shared by
|
|
224
|
+
* directory, expected PID and exactPid until their file identity changes or a
|
|
225
|
+
* connect/auth/stale-server failure triggers one rediscovery in the same call.
|
|
127
226
|
*/
|
|
128
227
|
export function createRpcClient(dir, expectedPid, onSelected, options = {}) {
|
|
129
228
|
const discoverOptions = {
|
|
130
229
|
exactPid: options.exactPid,
|
|
230
|
+
discover: options.discover,
|
|
131
231
|
};
|
|
132
232
|
let reportedSelection = false;
|
|
133
233
|
const reportSelected = (entry) => {
|
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
import { watch } from 'node:fs';
|
|
1
|
+
import { type Stats, watch } from 'node:fs';
|
|
2
|
+
type Metadata = Pick<Stats, 'mtimeMs' | 'size' | 'ino' | 'dev'>;
|
|
2
3
|
export interface TuiPreferencesWatchOptions {
|
|
3
4
|
watchDirectory?: typeof watch;
|
|
5
|
+
/** Internal filesystem seam; the first subscriber supplies a shared watcher's I/O. */
|
|
6
|
+
fs?: {
|
|
7
|
+
readFileSync?: (file: string) => string;
|
|
8
|
+
readFile?: (file: string) => Promise<string>;
|
|
9
|
+
statSync?: (file: string) => Metadata;
|
|
10
|
+
stat?: (file: string) => Promise<Metadata>;
|
|
11
|
+
};
|
|
4
12
|
}
|
|
5
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Share one directory watcher and metadata poll per canonical path, including
|
|
15
|
+
* across bundled copies. Native events use a 150 ms debounce; missed events
|
|
16
|
+
* recover through a 1,000 ms stat probe (formerly a 100 ms full-file poll).
|
|
17
|
+
* Watching the directory preserves atomic replacement and missing-file recovery.
|
|
18
|
+
* Only changed raw text notifies; each subscriber is isolated and independently
|
|
19
|
+
* disposable. The last unsubscribe stops both the native watcher and the poll.
|
|
20
|
+
*/
|
|
6
21
|
export declare function watchTuiPreferences(file: string, onChange: () => void, options?: TuiPreferencesWatchOptions): () => void;
|
|
22
|
+
export {};
|
|
@@ -1,38 +1,111 @@
|
|
|
1
|
-
import { readFileSync, watch } from 'node:fs';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { basename, dirname } from 'node:path';
|
|
1
|
+
import { readFileSync, realpathSync, statSync, watch, } from 'node:fs';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { clearTimeout, setTimeout } from 'node:timers';
|
|
5
|
-
|
|
5
|
+
function canonicalPath(file) {
|
|
6
|
+
const path = resolve(file);
|
|
7
|
+
try {
|
|
8
|
+
return realpathSync(path);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
// Resolve existing ancestors even when the preferences file is not yet present.
|
|
12
|
+
const parent = dirname(path);
|
|
13
|
+
return parent === path ? path : join(canonicalPath(parent), basename(path));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function identity(info) {
|
|
17
|
+
return info && `${info.dev}:${info.ino}:${info.mtimeMs}:${info.size}`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Share one directory watcher and metadata poll per canonical path, including
|
|
21
|
+
* across bundled copies. Native events use a 150 ms debounce; missed events
|
|
22
|
+
* recover through a 1,000 ms stat probe (formerly a 100 ms full-file poll).
|
|
23
|
+
* Watching the directory preserves atomic replacement and missing-file recovery.
|
|
24
|
+
* Only changed raw text notifies; each subscriber is isolated and independently
|
|
25
|
+
* disposable. The last unsubscribe stops both the native watcher and the poll.
|
|
26
|
+
*/
|
|
6
27
|
export function watchTuiPreferences(file, onChange, options = {}) {
|
|
7
|
-
const
|
|
28
|
+
const globals = globalThis;
|
|
29
|
+
const key = Symbol.for('cortexkit.common-auth.tui-prefs.watchers.v1');
|
|
30
|
+
const registry = globals[key] ?? new Map();
|
|
31
|
+
globals[key] = registry;
|
|
32
|
+
const path = canonicalPath(file);
|
|
33
|
+
const shared = registry.get(path);
|
|
34
|
+
if (shared)
|
|
35
|
+
return shared.subscribe(onChange);
|
|
36
|
+
const name = basename(path);
|
|
37
|
+
const read = options.fs?.readFile ?? ((file) => readFile(file, 'utf8'));
|
|
38
|
+
const probe = options.fs?.stat ?? stat;
|
|
8
39
|
let timer = null;
|
|
9
|
-
|
|
40
|
+
let pollTimer = null;
|
|
41
|
+
let lastMetadata = null;
|
|
10
42
|
let lastSeen = null;
|
|
43
|
+
// Seed synchronously so an immediate write cannot be absorbed as the baseline.
|
|
44
|
+
// The metadata is recorded only together with contents that were read, as in
|
|
45
|
+
// the probe below: if the first read fails, the probe keeps trying.
|
|
11
46
|
try {
|
|
12
|
-
|
|
47
|
+
const metadata = identity((options.fs?.statSync ?? statSync)(path));
|
|
48
|
+
lastSeen = (options.fs?.readFileSync ?? ((file) => readFileSync(file, 'utf8')))(path);
|
|
49
|
+
lastMetadata = metadata;
|
|
13
50
|
}
|
|
14
51
|
catch {
|
|
15
|
-
// Missing files
|
|
52
|
+
// Missing or unreadable files are picked up by the probe later.
|
|
16
53
|
}
|
|
17
54
|
let disposed = false;
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
55
|
+
const subscribers = new Set();
|
|
56
|
+
let checking = Promise.resolve();
|
|
57
|
+
const checkForChange = (native) => {
|
|
58
|
+
// Serialize native and poll checks so an older read cannot roll back state.
|
|
59
|
+
checking = checking.then(async () => {
|
|
60
|
+
if (disposed)
|
|
61
|
+
return;
|
|
62
|
+
const metadata = identity(await probe(path).catch(() => null));
|
|
63
|
+
if (!native && metadata === lastMetadata)
|
|
64
|
+
return;
|
|
65
|
+
// A missing file has nothing to read; remember that so the probe stays
|
|
66
|
+
// quiet until it reappears.
|
|
67
|
+
if (metadata === null) {
|
|
68
|
+
lastMetadata = null;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const text = await read(path).catch(() => null);
|
|
72
|
+
if (disposed)
|
|
73
|
+
return;
|
|
74
|
+
// Record the metadata only once its contents were read. A failed read
|
|
75
|
+
// leaves the old value, so the next probe sees a change and retries
|
|
76
|
+
// instead of skipping a file whose new contents were never observed.
|
|
77
|
+
if (text === null)
|
|
78
|
+
return;
|
|
79
|
+
lastMetadata = metadata;
|
|
80
|
+
if (text === lastSeen)
|
|
81
|
+
return;
|
|
82
|
+
lastSeen = text;
|
|
83
|
+
for (const subscriber of [...subscribers]) {
|
|
84
|
+
if (!subscribers.has(subscriber))
|
|
85
|
+
continue;
|
|
86
|
+
try {
|
|
87
|
+
subscriber.notify();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// A broken subscriber must not prevent the others from observing a change.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return checking;
|
|
24
95
|
};
|
|
25
96
|
const scheduleCheck = () => {
|
|
97
|
+
if (disposed)
|
|
98
|
+
return;
|
|
26
99
|
if (timer)
|
|
27
100
|
clearTimeout(timer);
|
|
28
101
|
timer = setTimeout(() => {
|
|
29
102
|
timer = null;
|
|
30
|
-
void checkForChange();
|
|
103
|
+
void checkForChange(true);
|
|
31
104
|
}, 150);
|
|
32
105
|
};
|
|
33
106
|
let watcher = null;
|
|
34
107
|
try {
|
|
35
|
-
watcher = (options.watchDirectory ?? watch)(dirname(
|
|
108
|
+
watcher = (options.watchDirectory ?? watch)(dirname(path), (_event, filename) => {
|
|
36
109
|
const eventName = filename?.toString();
|
|
37
110
|
const isOurs = eventName === name ||
|
|
38
111
|
(eventName?.startsWith(`${name}.`) && eventName.endsWith('.tmp'));
|
|
@@ -43,14 +116,12 @@ export function watchTuiPreferences(file, onChange, options = {}) {
|
|
|
43
116
|
watcher.on('error', () => {
|
|
44
117
|
// Polling continues if the native watcher fails after construction.
|
|
45
118
|
watcher?.close();
|
|
119
|
+
watcher = null;
|
|
46
120
|
});
|
|
47
121
|
}
|
|
48
122
|
catch {
|
|
49
|
-
|
|
50
|
-
return () => { };
|
|
123
|
+
// The probe still recovers a later creation, even with no native watcher.
|
|
51
124
|
}
|
|
52
|
-
// Poll independently: directory watchers can miss rename events.
|
|
53
|
-
let pollTimer = null;
|
|
54
125
|
const schedulePoll = () => {
|
|
55
126
|
if (disposed || pollTimer)
|
|
56
127
|
return;
|
|
@@ -58,17 +129,29 @@ export function watchTuiPreferences(file, onChange, options = {}) {
|
|
|
58
129
|
pollTimer = null;
|
|
59
130
|
if (disposed)
|
|
60
131
|
return;
|
|
61
|
-
void checkForChange().finally(schedulePoll);
|
|
62
|
-
},
|
|
132
|
+
void checkForChange(false).finally(schedulePoll);
|
|
133
|
+
}, 1_000);
|
|
63
134
|
pollTimer.unref();
|
|
64
135
|
};
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
136
|
+
const entry = {
|
|
137
|
+
subscribe: (notify) => {
|
|
138
|
+
const subscriber = { notify };
|
|
139
|
+
subscribers.add(subscriber);
|
|
140
|
+
return () => {
|
|
141
|
+
if (!subscribers.delete(subscriber) || subscribers.size > 0)
|
|
142
|
+
return;
|
|
143
|
+
disposed = true;
|
|
144
|
+
if (timer)
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
if (pollTimer)
|
|
147
|
+
clearTimeout(pollTimer);
|
|
148
|
+
watcher?.close();
|
|
149
|
+
if (registry.get(path) === entry)
|
|
150
|
+
registry.delete(path);
|
|
151
|
+
};
|
|
152
|
+
},
|
|
73
153
|
};
|
|
154
|
+
registry.set(path, entry);
|
|
155
|
+
schedulePoll();
|
|
156
|
+
return entry.subscribe(onChange);
|
|
74
157
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/common-auth",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.4",
|
|
4
4
|
"description": "Shared code for the CortexKit auth plugins: account pool, quota and routing, commands and auth menu, OpenCode 2 hooks, Claustrum custody, and plumbing (loopback RPC, file locks, logger, sidebar state, TUI preferences and build).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|