@cortexkit/common-auth 0.1.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/LICENSE +21 -0
- package/README.md +10 -0
- package/dist/fs/atomic-write.d.ts +5 -0
- package/dist/fs/atomic-write.js +22 -0
- package/dist/fs/index.d.ts +5 -0
- package/dist/fs/index.js +3 -0
- package/dist/fs/lock-constants.d.ts +14 -0
- package/dist/fs/lock-constants.js +14 -0
- package/dist/fs/refresh-file-lock.d.ts +18 -0
- package/dist/fs/refresh-file-lock.js +392 -0
- package/dist/fs/with-lock.d.ts +32 -0
- package/dist/fs/with-lock.js +48 -0
- package/dist/logger/capture-sink.d.ts +14 -0
- package/dist/logger/capture-sink.js +13 -0
- package/dist/logger/engine.d.ts +44 -0
- package/dist/logger/engine.js +199 -0
- package/dist/logger/index.d.ts +6 -0
- package/dist/logger/index.js +3 -0
- package/dist/logger/redact.d.ts +11 -0
- package/dist/logger/redact.js +45 -0
- package/dist/rpc/index.d.ts +10 -0
- package/dist/rpc/index.js +5 -0
- package/dist/rpc/notifications.d.ts +29 -0
- package/dist/rpc/notifications.js +57 -0
- package/dist/rpc/port-file.d.ts +20 -0
- package/dist/rpc/port-file.js +164 -0
- package/dist/rpc/rpc-client.d.ts +8 -0
- package/dist/rpc/rpc-client.js +51 -0
- package/dist/rpc/rpc-server.d.ts +19 -0
- package/dist/rpc/rpc-server.js +135 -0
- package/dist/rpc/server-registry.d.ts +11 -0
- package/dist/rpc/server-registry.js +47 -0
- package/dist/sidebar-file/index.d.ts +2 -0
- package/dist/sidebar-file/index.js +1 -0
- package/dist/sidebar-file/sidebar-file.d.ts +23 -0
- package/dist/sidebar-file/sidebar-file.js +84 -0
- package/dist/tui/index.d.ts +8 -0
- package/dist/tui/index.js +21 -0
- package/dist/tui-build/build-tui.d.ts +23 -0
- package/dist/tui-build/build-tui.js +190 -0
- package/dist/tui-build/index.d.ts +3 -0
- package/dist/tui-build/index.js +2 -0
- package/dist/tui-build/publish-list.d.ts +2 -0
- package/dist/tui-build/publish-list.js +20 -0
- package/dist/tui-build/walker.d.ts +8 -0
- package/dist/tui-build/walker.js +171 -0
- package/dist/tui-prefs/index.d.ts +2 -0
- package/dist/tui-prefs/index.js +2 -0
- package/dist/tui-prefs/tui-preferences.d.ts +25 -0
- package/dist/tui-prefs/tui-preferences.js +77 -0
- package/dist/tui-prefs/watcher.d.ts +6 -0
- package/dist/tui-prefs/watcher.js +74 -0
- package/package.json +89 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, existsSync, renameSync, statSync, } from 'node:fs';
|
|
2
|
+
import { createRedactor } from './redact.js';
|
|
3
|
+
const ORDER = {
|
|
4
|
+
error: 0,
|
|
5
|
+
warn: 1,
|
|
6
|
+
info: 2,
|
|
7
|
+
debug: 3,
|
|
8
|
+
trace: 4,
|
|
9
|
+
};
|
|
10
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
11
|
+
/**
|
|
12
|
+
* Where lines are written, and the level floor, as the host supplied them.
|
|
13
|
+
*
|
|
14
|
+
* Both start unset. A host decides where its log lives — that decision reads
|
|
15
|
+
* host environment variables and host directories, neither of which belongs in
|
|
16
|
+
* shared code — and calls `initLogger` before it runs any command. Until then
|
|
17
|
+
* every `log.*` call is a silent no-op: throwing would turn the first command a
|
|
18
|
+
* host forgot to wire into a crash, and buffering would hold credential-bearing
|
|
19
|
+
* lines for an init that may never arrive.
|
|
20
|
+
*/
|
|
21
|
+
let logFileSource;
|
|
22
|
+
let initLevelSource;
|
|
23
|
+
let runtimeLevel;
|
|
24
|
+
let redactor = createRedactor();
|
|
25
|
+
let captureSink;
|
|
26
|
+
/**
|
|
27
|
+
* Point the logger at a host's file and level. Idempotent: calling it again
|
|
28
|
+
* replaces both. A runtime level installed by `setLogLevel` is deliberately
|
|
29
|
+
* left alone, because it is the operator's explicit choice and outranks the
|
|
30
|
+
* floor a host computed at start-up.
|
|
31
|
+
*/
|
|
32
|
+
export function initLogger(options) {
|
|
33
|
+
logFileSource = options.file;
|
|
34
|
+
initLevelSource = options.level;
|
|
35
|
+
redactor = createRedactor(options);
|
|
36
|
+
captureSink = options.captureSink;
|
|
37
|
+
}
|
|
38
|
+
export function setLogLevel(l) {
|
|
39
|
+
if (l === undefined || l in ORDER)
|
|
40
|
+
runtimeLevel = l;
|
|
41
|
+
}
|
|
42
|
+
function logFilePath() {
|
|
43
|
+
if (logFileSource === undefined)
|
|
44
|
+
return undefined;
|
|
45
|
+
const resolved = typeof logFileSource === 'function' ? logFileSource() : logFileSource;
|
|
46
|
+
return resolved || undefined;
|
|
47
|
+
}
|
|
48
|
+
function configuredLevel() {
|
|
49
|
+
if (runtimeLevel)
|
|
50
|
+
return runtimeLevel;
|
|
51
|
+
const floor = typeof initLevelSource === 'function' ? initLevelSource() : initLevelSource;
|
|
52
|
+
if (floor && floor in ORDER)
|
|
53
|
+
return floor;
|
|
54
|
+
return 'info';
|
|
55
|
+
}
|
|
56
|
+
let buffer = [];
|
|
57
|
+
let timer;
|
|
58
|
+
const ROTATE_KEEP = 3;
|
|
59
|
+
function chmodPrivate(path) {
|
|
60
|
+
try {
|
|
61
|
+
chmodSync(path, 0o600);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* never throw */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function rotateIfNeeded(f) {
|
|
68
|
+
try {
|
|
69
|
+
if (!(existsSync(f) && statSync(f).size >= MAX_BYTES))
|
|
70
|
+
return;
|
|
71
|
+
for (let i = ROTATE_KEEP - 1; i >= 1; i--) {
|
|
72
|
+
if (existsSync(`${f}.${i}`)) {
|
|
73
|
+
const rotated = `${f}.${i + 1}`;
|
|
74
|
+
renameSync(`${f}.${i}`, rotated);
|
|
75
|
+
chmodPrivate(rotated);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const rotated = `${f}.1`;
|
|
79
|
+
renameSync(f, rotated);
|
|
80
|
+
chmodPrivate(rotated);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* never throw */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Write whatever is buffered. Safe to call synchronously from a process-exit
|
|
88
|
+
* handler, which is how each host drains the buffer on shutdown.
|
|
89
|
+
*/
|
|
90
|
+
export function flushLogs() {
|
|
91
|
+
if (timer) {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
timer = undefined;
|
|
94
|
+
}
|
|
95
|
+
if (!buffer.length)
|
|
96
|
+
return;
|
|
97
|
+
let file;
|
|
98
|
+
try {
|
|
99
|
+
file = logFilePath();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
buffer = [];
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const text = buffer.join('');
|
|
106
|
+
buffer = [];
|
|
107
|
+
if (!file)
|
|
108
|
+
return;
|
|
109
|
+
try {
|
|
110
|
+
rotateIfNeeded(file);
|
|
111
|
+
if (existsSync(file))
|
|
112
|
+
chmodPrivate(file);
|
|
113
|
+
appendFileSync(file, text, { encoding: 'utf8', mode: 0o600 });
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
/* never throw */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function schedule() {
|
|
120
|
+
if (!timer)
|
|
121
|
+
timer = setTimeout(() => {
|
|
122
|
+
timer = undefined;
|
|
123
|
+
flushLogs();
|
|
124
|
+
}, 500);
|
|
125
|
+
}
|
|
126
|
+
function safeSerialize(data) {
|
|
127
|
+
try {
|
|
128
|
+
return ` ${JSON.stringify(data)}`;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return ' [unserializable]';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function emit(channel, level, message, data) {
|
|
135
|
+
if (logFileSource === undefined)
|
|
136
|
+
return;
|
|
137
|
+
try {
|
|
138
|
+
if (ORDER[level] > ORDER[configuredLevel()])
|
|
139
|
+
return;
|
|
140
|
+
const scrubbedMessage = redactor.redactStrings(message);
|
|
141
|
+
let scrubbedData;
|
|
142
|
+
try {
|
|
143
|
+
scrubbedData = redactor.redact(data);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
scrubbedData = '[unserializable]';
|
|
147
|
+
}
|
|
148
|
+
const line = `[${new Date().toISOString()}] ${level.toUpperCase()} [${channel}] ${scrubbedMessage}` +
|
|
149
|
+
(data === undefined ? '' : safeSerialize(scrubbedData)) +
|
|
150
|
+
'\n';
|
|
151
|
+
// A failing observer must not prevent file logging or escape into the host.
|
|
152
|
+
try {
|
|
153
|
+
captureSink?.({
|
|
154
|
+
channel,
|
|
155
|
+
level,
|
|
156
|
+
message: scrubbedMessage,
|
|
157
|
+
data: scrubbedData,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
catch { }
|
|
161
|
+
buffer.push(line);
|
|
162
|
+
if (buffer.length >= 50)
|
|
163
|
+
flushLogs();
|
|
164
|
+
else
|
|
165
|
+
schedule();
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Provider and redaction failures must never turn diagnostics into a host crash.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
export function createLogger(channel) {
|
|
172
|
+
return {
|
|
173
|
+
error: (m, d) => emit(channel, 'error', m, d),
|
|
174
|
+
warn: (m, d) => emit(channel, 'warn', m, d),
|
|
175
|
+
info: (m, d) => emit(channel, 'info', m, d),
|
|
176
|
+
debug: (m, d) => emit(channel, 'debug', m, d),
|
|
177
|
+
trace: (m, d) => emit(channel, 'trace', m, d),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
export async function flushForTest() {
|
|
181
|
+
flushLogs();
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Return the logger to its uninitialised state. Only a test needs this: a
|
|
185
|
+
* single process runs every test file, so a file path left over from one test
|
|
186
|
+
* would keep a later "logger was never initialised" case writing lines.
|
|
187
|
+
*/
|
|
188
|
+
export function resetLoggerForTest() {
|
|
189
|
+
buffer = [];
|
|
190
|
+
if (timer) {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
timer = undefined;
|
|
193
|
+
}
|
|
194
|
+
logFileSource = undefined;
|
|
195
|
+
initLevelSource = undefined;
|
|
196
|
+
runtimeLevel = undefined;
|
|
197
|
+
redactor = createRedactor();
|
|
198
|
+
captureSink = undefined;
|
|
199
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { CaptureSink, LogTestRecord } from './capture-sink.js';
|
|
2
|
+
export { createCaptureSink } from './capture-sink.js';
|
|
3
|
+
export type { InitLoggerOptions, Level } from './engine.js';
|
|
4
|
+
export { createLogger, flushForTest, flushLogs, initLogger, resetLoggerForTest, setLogLevel, } from './engine.js';
|
|
5
|
+
export type { RedactionOptions, Redactor } from './redact.js';
|
|
6
|
+
export { createRedactor, redact, redactStrings } from './redact.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface RedactionOptions {
|
|
2
|
+
extraSecretKeys?: (normalizedKey: string) => boolean;
|
|
3
|
+
extraValuePatterns?: RegExp[];
|
|
4
|
+
}
|
|
5
|
+
export declare function createRedactor(options?: RedactionOptions): {
|
|
6
|
+
redact: (value: unknown) => unknown;
|
|
7
|
+
redactStrings: (value: unknown) => unknown;
|
|
8
|
+
};
|
|
9
|
+
export type Redactor = ReturnType<typeof createRedactor>;
|
|
10
|
+
export declare const redact: (value: unknown) => unknown;
|
|
11
|
+
export declare const redactStrings: (value: unknown) => unknown;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const SECRET_KEY_EXACT = /^(authorization|x-api-key|cookie|set-cookie|refresh|access|token)$/i;
|
|
2
|
+
const TOKEN_VALUE = /\b(Bearer\s+[\w.-]+|sk-[\w-]+|eyJ[\w.-]+)\b|ckh_[A-Za-z0-9_-]{20,}/g;
|
|
3
|
+
const MASK = '***REDACTED***';
|
|
4
|
+
export function createRedactor(options = {}) {
|
|
5
|
+
const patterns = [TOKEN_VALUE, ...(options.extraValuePatterns ?? [])].map((pattern) => new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`));
|
|
6
|
+
function isSecretKey(key) {
|
|
7
|
+
const normalized = key.toLowerCase().replace(/[-_]/g, '');
|
|
8
|
+
return (SECRET_KEY_EXACT.test(key) ||
|
|
9
|
+
normalized.includes('apikey') ||
|
|
10
|
+
normalized.endsWith('secret') ||
|
|
11
|
+
normalized.endsWith('password') ||
|
|
12
|
+
(normalized.endsWith('token') && !normalized.endsWith('tokens')) ||
|
|
13
|
+
!!options.extraSecretKeys?.(normalized));
|
|
14
|
+
}
|
|
15
|
+
function walk(value, keys, seen) {
|
|
16
|
+
if (typeof value === 'string') {
|
|
17
|
+
return patterns.reduce((text, pattern) => text.replace(pattern, MASK), value);
|
|
18
|
+
}
|
|
19
|
+
if (!value || typeof value !== 'object')
|
|
20
|
+
return value;
|
|
21
|
+
if (seen.has(value))
|
|
22
|
+
return '[Circular]';
|
|
23
|
+
seen.add(value);
|
|
24
|
+
try {
|
|
25
|
+
if (Array.isArray(value))
|
|
26
|
+
return value.map((item) => walk(item, keys, seen));
|
|
27
|
+
// Define own properties so a diagnostic __proto__ key cannot change the output prototype.
|
|
28
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
29
|
+
key,
|
|
30
|
+
keys && isSecretKey(key) ? MASK : walk(item, keys, seen),
|
|
31
|
+
]));
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
seen.delete(value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
redact: (value) => walk(value, true, new WeakSet()),
|
|
39
|
+
// Schema keys describe arguments, not credentials; only their string values are scrubbed.
|
|
40
|
+
redactStrings: (value) => walk(value, false, new WeakSet()),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const defaultRedactor = createRedactor();
|
|
44
|
+
export const redact = defaultRedactor.redact;
|
|
45
|
+
export const redactStrings = defaultRedactor.redactStrings;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './notifications.js';
|
|
2
|
+
export * from './port-file.js';
|
|
3
|
+
export * from './rpc-client.js';
|
|
4
|
+
export * from './rpc-server.js';
|
|
5
|
+
export * from './server-registry.js';
|
|
6
|
+
/** Pass methods bound to the caller’s logger channel; RPC uses no-op methods when no logger is supplied. */
|
|
7
|
+
export interface RpcLogChannel {
|
|
8
|
+
warn(message: string, data?: unknown): void;
|
|
9
|
+
debug(message: string, data?: unknown): void;
|
|
10
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface ApplyRequest {
|
|
2
|
+
command: string;
|
|
3
|
+
arguments: string;
|
|
4
|
+
sessionId?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ApplyResult {
|
|
7
|
+
text: string;
|
|
8
|
+
knobs: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
export interface OpenDialogPayload {
|
|
11
|
+
command: string;
|
|
12
|
+
text: string;
|
|
13
|
+
knobs: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
export interface RpcNotification {
|
|
16
|
+
id: number;
|
|
17
|
+
type: 'open-dialog';
|
|
18
|
+
payload: OpenDialogPayload;
|
|
19
|
+
sessionId?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface NotificationScope {
|
|
22
|
+
rpcRoot: string;
|
|
23
|
+
directoryPrefix: string;
|
|
24
|
+
registrationSessionId: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function pushNotification(scope: NotificationScope, payload: OpenDialogPayload, sessionId?: string): void;
|
|
27
|
+
export declare function drainNotifications(scope: NotificationScope, lastReceivedId?: number, sessionId?: string): RpcNotification[];
|
|
28
|
+
export declare function isTuiConnected(scope: NotificationScope, sessionId: string): boolean;
|
|
29
|
+
export declare function resetNotificationsForTest(scope: NotificationScope): void;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const queues = new Map();
|
|
2
|
+
function state(scope) {
|
|
3
|
+
const key = JSON.stringify([
|
|
4
|
+
scope.rpcRoot,
|
|
5
|
+
scope.directoryPrefix,
|
|
6
|
+
scope.registrationSessionId,
|
|
7
|
+
]);
|
|
8
|
+
let value = queues.get(key);
|
|
9
|
+
if (!value) {
|
|
10
|
+
value = { queue: [], nextId: 1, lastDrainAtBySession: new Map() };
|
|
11
|
+
queues.set(key, value);
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
const QUEUE_CAP = 100;
|
|
16
|
+
const TUI_CONNECTED_WINDOW_MS = 3_000;
|
|
17
|
+
export function pushNotification(scope, payload, sessionId) {
|
|
18
|
+
const value = state(scope);
|
|
19
|
+
value.queue.push({
|
|
20
|
+
id: value.nextId++,
|
|
21
|
+
type: 'open-dialog',
|
|
22
|
+
payload,
|
|
23
|
+
sessionId,
|
|
24
|
+
});
|
|
25
|
+
if (value.queue.length > QUEUE_CAP)
|
|
26
|
+
value.queue = value.queue.slice(-QUEUE_CAP);
|
|
27
|
+
}
|
|
28
|
+
export function drainNotifications(scope, lastReceivedId = 0, sessionId) {
|
|
29
|
+
const value = state(scope);
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
if (sessionId !== undefined)
|
|
32
|
+
value.lastDrainAtBySession.set(sessionId, now);
|
|
33
|
+
const matches = (n) => sessionId === undefined ||
|
|
34
|
+
n.sessionId === undefined ||
|
|
35
|
+
n.sessionId === sessionId;
|
|
36
|
+
if (lastReceivedId > 0) {
|
|
37
|
+
value.queue = value.queue.filter((n) => {
|
|
38
|
+
if (n.id > lastReceivedId)
|
|
39
|
+
return true;
|
|
40
|
+
if (sessionId === undefined)
|
|
41
|
+
return true;
|
|
42
|
+
return n.sessionId !== sessionId;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return value.queue.filter((n) => n.id > lastReceivedId && matches(n));
|
|
46
|
+
}
|
|
47
|
+
export function isTuiConnected(scope, sessionId) {
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
const at = state(scope).lastDrainAtBySession.get(sessionId) ?? 0;
|
|
50
|
+
return at > 0 && now - at < TUI_CONNECTED_WINDOW_MS;
|
|
51
|
+
}
|
|
52
|
+
export function resetNotificationsForTest(scope) {
|
|
53
|
+
const value = state(scope);
|
|
54
|
+
value.queue = [];
|
|
55
|
+
value.nextId = 1;
|
|
56
|
+
value.lastDrainAtBySession.clear();
|
|
57
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RpcLogChannel } from './index.js';
|
|
2
|
+
export interface PortFileEntry {
|
|
3
|
+
port: number;
|
|
4
|
+
token: string;
|
|
5
|
+
pid: number;
|
|
6
|
+
startedAt: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function createManagedRpcStateDirPredicate(directoryPrefix: string): (name: string) => boolean;
|
|
9
|
+
export declare function isManagedRpcStateDir(name: string, directoryPrefix: string): boolean;
|
|
10
|
+
export declare function getRpcDir(rpcRoot: string, directoryPrefix: string, projectDirectory: string): string;
|
|
11
|
+
export declare function writePortFile(dir: string, entry: {
|
|
12
|
+
port: number;
|
|
13
|
+
token: string;
|
|
14
|
+
pid: number;
|
|
15
|
+
}, options?: {
|
|
16
|
+
secureDir?: boolean;
|
|
17
|
+
beforeWrite?: () => void | Promise<void>;
|
|
18
|
+
}): Promise<string>;
|
|
19
|
+
export declare function sweepRpcState(root: string, activeDir: string, isManagedDir: (name: string) => boolean, log?: RpcLogChannel): Promise<void>;
|
|
20
|
+
export declare function discoverPortFile(dir: string, expectedPid?: number): Promise<PortFileEntry | null>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, readdir, readFile, rename, rmdir, unlink, writeFile, } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
function pidAlive(pid) {
|
|
5
|
+
try {
|
|
6
|
+
process.kill(pid, 0);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
return error.code === 'EPERM';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function createManagedRpcStateDirPredicate(directoryPrefix) {
|
|
14
|
+
return (name) => isManagedRpcStateDir(name, directoryPrefix);
|
|
15
|
+
}
|
|
16
|
+
export function isManagedRpcStateDir(name, directoryPrefix) {
|
|
17
|
+
if (/^[0-9a-f]{16}$/.test(name))
|
|
18
|
+
return true;
|
|
19
|
+
return (name.startsWith(directoryPrefix) &&
|
|
20
|
+
/^[0-9a-f]{16}$/.test(name.slice(directoryPrefix.length)));
|
|
21
|
+
}
|
|
22
|
+
export function getRpcDir(rpcRoot, directoryPrefix, projectDirectory) {
|
|
23
|
+
return join(rpcRoot, directoryPrefix +
|
|
24
|
+
createHash('sha256').update(projectDirectory).digest('hex').slice(0, 16));
|
|
25
|
+
}
|
|
26
|
+
function isUsablePortFileEntry(value) {
|
|
27
|
+
return (value !== null &&
|
|
28
|
+
typeof value === 'object' &&
|
|
29
|
+
!Array.isArray(value) &&
|
|
30
|
+
typeof value.pid === 'number' &&
|
|
31
|
+
Number.isFinite(value.pid) &&
|
|
32
|
+
typeof value.port === 'number' &&
|
|
33
|
+
Number.isFinite(value.port));
|
|
34
|
+
}
|
|
35
|
+
async function removeCorruptPortFile(portFile, log) {
|
|
36
|
+
log?.debug('rpc corrupt port file', { pid: process.pid, portFile });
|
|
37
|
+
await unlink(portFile).catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
export async function writePortFile(dir, entry, options = {}) {
|
|
40
|
+
// The directory can be removed by another project's sweep between our
|
|
41
|
+
// mkdir and the first writeFile/rename, so the whole create-then-rename
|
|
42
|
+
// unit is retried once on ENOENT. The retry recreates the directory; a
|
|
43
|
+
// persistent ENOENT (e.g. permission, read-only parent) will surface on
|
|
44
|
+
// the second attempt — failing fast beats an unbounded loop.
|
|
45
|
+
const writeOnce = async () => {
|
|
46
|
+
await mkdir(dir, {
|
|
47
|
+
recursive: true,
|
|
48
|
+
mode: options.secureDir ? 0o700 : undefined,
|
|
49
|
+
});
|
|
50
|
+
if (options.secureDir)
|
|
51
|
+
await chmod(dir, 0o700);
|
|
52
|
+
await options.beforeWrite?.();
|
|
53
|
+
const full = { ...entry, startedAt: Date.now() };
|
|
54
|
+
const target = join(dir, `port-${entry.pid}.json`);
|
|
55
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
56
|
+
await writeFile(tmp, JSON.stringify(full), {
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
mode: 0o600,
|
|
59
|
+
});
|
|
60
|
+
await rename(tmp, target);
|
|
61
|
+
return target;
|
|
62
|
+
};
|
|
63
|
+
try {
|
|
64
|
+
return await writeOnce();
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code === 'ENOENT') {
|
|
68
|
+
return await writeOnce();
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export async function sweepRpcState(root, activeDir, isManagedDir, log) {
|
|
74
|
+
let projectDirs;
|
|
75
|
+
try {
|
|
76
|
+
projectDirs = await readdir(root, { withFileTypes: true });
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error.code === 'ENOENT')
|
|
80
|
+
return;
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
const active = resolve(activeDir);
|
|
84
|
+
for (const projectDir of projectDirs) {
|
|
85
|
+
if (!projectDir.isDirectory() || !isManagedDir(projectDir.name)) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const dir = join(root, projectDir.name);
|
|
89
|
+
let names;
|
|
90
|
+
try {
|
|
91
|
+
names = await readdir(dir);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error.code === 'ENOENT')
|
|
95
|
+
continue;
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
for (const name of names) {
|
|
99
|
+
if (!name.startsWith('port-') || !name.endsWith('.json'))
|
|
100
|
+
continue;
|
|
101
|
+
const portFile = join(dir, name);
|
|
102
|
+
let raw;
|
|
103
|
+
try {
|
|
104
|
+
raw = await readFile(portFile, 'utf8');
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (raw === undefined)
|
|
110
|
+
continue;
|
|
111
|
+
let parsed;
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(raw);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
await removeCorruptPortFile(portFile, log);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!isUsablePortFileEntry(parsed)) {
|
|
120
|
+
await removeCorruptPortFile(portFile, log);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const entry = parsed;
|
|
124
|
+
if (!pidAlive(entry.pid))
|
|
125
|
+
await unlink(portFile).catch(() => { });
|
|
126
|
+
}
|
|
127
|
+
if (resolve(dir) !== active)
|
|
128
|
+
await rmdir(dir).catch(() => { });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export async function discoverPortFile(dir, expectedPid) {
|
|
132
|
+
let names;
|
|
133
|
+
try {
|
|
134
|
+
names = await readdir(dir);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const live = [];
|
|
140
|
+
for (const name of names) {
|
|
141
|
+
if (!name.startsWith('port-') || !name.endsWith('.json'))
|
|
142
|
+
continue;
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(await readFile(join(dir, name), 'utf8'));
|
|
145
|
+
if (isUsablePortFileEntry(parsed)) {
|
|
146
|
+
if (pidAlive(parsed.pid))
|
|
147
|
+
live.push(parsed);
|
|
148
|
+
else
|
|
149
|
+
await unlink(join(dir, name)).catch(() => { });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch { }
|
|
153
|
+
}
|
|
154
|
+
if (live.length === 0)
|
|
155
|
+
return null;
|
|
156
|
+
const candidates = expectedPid !== undefined && expectedPid >= 1
|
|
157
|
+
? live.filter((entry) => entry.pid === expectedPid)
|
|
158
|
+
: [];
|
|
159
|
+
const entries = candidates.length > 0 ? candidates : live;
|
|
160
|
+
const sortTime = (entry) => typeof entry.startedAt === 'number' && Number.isFinite(entry.startedAt)
|
|
161
|
+
? entry.startedAt
|
|
162
|
+
: -Infinity;
|
|
163
|
+
return entries.sort((a, b) => sortTime(b) - sortTime(a))[0] ?? null;
|
|
164
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ApplyRequest, ApplyResult, RpcNotification } from './notifications.js';
|
|
2
|
+
import { type PortFileEntry } from './port-file.js';
|
|
3
|
+
export interface RpcClient {
|
|
4
|
+
pending: (lastReceivedId: number, sessionId?: string) => Promise<RpcNotification[]>;
|
|
5
|
+
apply: (request: ApplyRequest, timeoutMs?: number) => Promise<ApplyResult>;
|
|
6
|
+
}
|
|
7
|
+
export declare const DEFAULT_RPC_TIMEOUT_MS = 2000;
|
|
8
|
+
export declare function createRpcClient(dir: string, expectedPid?: number, onSelected?: (entry: PortFileEntry | null) => void): RpcClient;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { discoverPortFile } from './port-file.js';
|
|
2
|
+
export const DEFAULT_RPC_TIMEOUT_MS = 2_000;
|
|
3
|
+
async function call(dir, expectedPid, onSelected, method, params, timeoutMs = DEFAULT_RPC_TIMEOUT_MS) {
|
|
4
|
+
const entry = await discoverPortFile(dir, expectedPid);
|
|
5
|
+
onSelected?.(entry);
|
|
6
|
+
if (!entry)
|
|
7
|
+
return null;
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(`http://127.0.0.1:${entry.port}/rpc/${method}`, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: {
|
|
14
|
+
'content-type': 'application/json',
|
|
15
|
+
authorization: `Bearer ${entry.token}`,
|
|
16
|
+
},
|
|
17
|
+
body: JSON.stringify(params),
|
|
18
|
+
signal: controller.signal,
|
|
19
|
+
});
|
|
20
|
+
if (!res.ok)
|
|
21
|
+
return null;
|
|
22
|
+
return (await res.json());
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function createRpcClient(dir, expectedPid, onSelected) {
|
|
32
|
+
let reportedSelection = false;
|
|
33
|
+
const reportSelected = (entry) => {
|
|
34
|
+
if (reportedSelection)
|
|
35
|
+
return;
|
|
36
|
+
reportedSelection = true;
|
|
37
|
+
onSelected?.(entry);
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
async pending(lastReceivedId, sessionId) {
|
|
41
|
+
const out = await call(dir, expectedPid, reportSelected, 'pending-notifications', { lastReceivedId, sessionId });
|
|
42
|
+
return out?.messages ?? [];
|
|
43
|
+
},
|
|
44
|
+
async apply(request, timeoutMs) {
|
|
45
|
+
const out = await call(dir, expectedPid, reportSelected, 'apply', {
|
|
46
|
+
...request,
|
|
47
|
+
}, timeoutMs);
|
|
48
|
+
return out ?? { text: 'apply failed', knobs: {} };
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { RpcLogChannel } from './index.js';
|
|
2
|
+
import type { ApplyRequest, ApplyResult, RpcNotification } from './notifications.js';
|
|
3
|
+
export interface RpcServerHandle {
|
|
4
|
+
port: number;
|
|
5
|
+
token: string;
|
|
6
|
+
stop: () => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export interface RpcServerOptions {
|
|
9
|
+
dir: string;
|
|
10
|
+
log?: RpcLogChannel;
|
|
11
|
+
isManagedDir: (name: string) => boolean;
|
|
12
|
+
secureDir?: boolean;
|
|
13
|
+
sweepRoot?: string;
|
|
14
|
+
drain: (lastReceivedId: number, sessionId?: string) => RpcNotification[];
|
|
15
|
+
apply: (request: ApplyRequest) => Promise<ApplyResult>;
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
receiptTimeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function startRpcServer(options: RpcServerOptions): Promise<RpcServerHandle>;
|