@cortexkit/common-auth 0.7.0 → 0.9.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/dist/logger/engine.d.ts +24 -3
- package/dist/logger/engine.js +5 -1
- package/dist/logger/index.d.ts +1 -1
- package/dist/logger/redact.js +42 -4
- package/dist/rpc/notifications.d.ts +25 -1
- package/dist/rpc/notifications.js +28 -6
- package/dist/rpc/port-file.d.ts +10 -1
- package/dist/rpc/port-file.js +50 -16
- package/dist/rpc/rpc-client.d.ts +20 -3
- package/dist/rpc/rpc-client.js +132 -30
- package/dist/rpc/rpc-server.d.ts +18 -0
- package/dist/rpc/rpc-server.js +98 -14
- package/dist/sidebar-file/sidebar-file.d.ts +10 -0
- package/dist/sidebar-file/sidebar-file.js +1 -0
- package/dist/store/attribution.d.ts +13 -2
- package/dist/store/errors.d.ts +1 -1
- package/dist/store/identity.d.ts +2 -0
- package/dist/store/identity.js +9 -1
- package/dist/store/mutate.d.ts +5 -1
- package/dist/store/mutate.js +14 -2
- package/dist/store/pool.d.ts +7 -0
- package/dist/store/refresh.d.ts +17 -1
- package/dist/store/refresh.js +35 -2
- package/dist/store/rows.d.ts +9 -2
- package/dist/store/rows.js +28 -10
- package/dist/store/schema.d.ts +40 -0
- package/dist/store/schema.js +87 -3
- package/dist/store/torn.js +8 -1
- package/dist/tui-build/build-tui.js +39 -4
- package/package.json +7 -5
package/dist/logger/engine.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { CaptureSink } from './capture-sink.js';
|
|
2
2
|
import { type RedactionOptions } from './redact.js';
|
|
3
3
|
export type Level = 'error' | 'warn' | 'info' | 'debug' | 'trace';
|
|
4
|
+
/** A logger that writes to a file, and to a capture sink when one is given. */
|
|
4
5
|
export interface InitLoggerOptions extends RedactionOptions {
|
|
6
|
+
/** Receives every emitted record, scrubbed, alongside the file. */
|
|
5
7
|
captureSink?: CaptureSink;
|
|
6
8
|
/**
|
|
7
9
|
* Path of the file lines are appended to, or a function returning it.
|
|
@@ -15,6 +17,25 @@ export interface InitLoggerOptions extends RedactionOptions {
|
|
|
15
17
|
/** Level floor applied when no `setLogLevel` call has overridden it. */
|
|
16
18
|
level?: Level | (() => Level | undefined);
|
|
17
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* A logger with no file: the capture sink is its only destination. Nothing
|
|
22
|
+
* is written to disk or printed, for a host that forwards records to its own
|
|
23
|
+
* log.
|
|
24
|
+
*/
|
|
25
|
+
export interface SinkOnlyLoggerOptions extends RedactionOptions {
|
|
26
|
+
/** Receives every emitted record, scrubbed. */
|
|
27
|
+
captureSink: CaptureSink;
|
|
28
|
+
file?: undefined;
|
|
29
|
+
/** Level floor applied when no `setLogLevel` call has overridden it. */
|
|
30
|
+
level?: Level | (() => Level | undefined);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* What a logger is configured with: a file (`InitLoggerOptions`, the shape
|
|
34
|
+
* every release before this one accepted, kept as its own interface so a
|
|
35
|
+
* caller deriving a type from it with `Pick` or `extends` keeps compiling) or
|
|
36
|
+
* a capture sink alone.
|
|
37
|
+
*/
|
|
38
|
+
export type LoggerOptions = InitLoggerOptions | SinkOnlyLoggerOptions;
|
|
18
39
|
export interface ChannelLogger {
|
|
19
40
|
error(message: string, data?: unknown): void;
|
|
20
41
|
warn(message: string, data?: unknown): void;
|
|
@@ -37,7 +58,7 @@ export interface LoggerInstance {
|
|
|
37
58
|
* alone, because it is the operator's explicit choice and outranks the
|
|
38
59
|
* floor a host computed at start-up.
|
|
39
60
|
*/
|
|
40
|
-
configure(options:
|
|
61
|
+
configure(options: LoggerOptions): void;
|
|
41
62
|
/** Operator override of the level floor; undefined removes it. */
|
|
42
63
|
setLogLevel(level: Level | undefined): void;
|
|
43
64
|
/**
|
|
@@ -53,14 +74,14 @@ export interface LoggerInstance {
|
|
|
53
74
|
* uses this instead of `initLogger`, so neither replaces the other's file,
|
|
54
75
|
* level, redaction or capture sink.
|
|
55
76
|
*/
|
|
56
|
-
export declare function createLoggerInstance(options:
|
|
77
|
+
export declare function createLoggerInstance(options: LoggerOptions): LoggerInstance;
|
|
57
78
|
/**
|
|
58
79
|
* Point the module's default logger at a host's file and level, and return
|
|
59
80
|
* it. Idempotent: calling it again replaces both. A runtime level installed
|
|
60
81
|
* by `setLogLevel` is deliberately left alone, because it is the operator's
|
|
61
82
|
* explicit choice and outranks the floor a host computed at start-up.
|
|
62
83
|
*/
|
|
63
|
-
export declare function initLogger(options:
|
|
84
|
+
export declare function initLogger(options: LoggerOptions): LoggerInstance;
|
|
64
85
|
export declare function setLogLevel(l: Level | undefined): void;
|
|
65
86
|
/**
|
|
66
87
|
* Write whatever the default logger has buffered. Safe to call synchronously
|
package/dist/logger/engine.js
CHANGED
|
@@ -126,7 +126,8 @@ function createEngine(options) {
|
|
|
126
126
|
}, 500);
|
|
127
127
|
}
|
|
128
128
|
function emit(channel, level, message, data) {
|
|
129
|
-
|
|
129
|
+
// Unconfigured (neither a file nor a sink) stays a silent no-op.
|
|
130
|
+
if (logFileSource === undefined && captureSink === undefined)
|
|
130
131
|
return;
|
|
131
132
|
try {
|
|
132
133
|
if (ORDER[level] > ORDER[configuredLevel()])
|
|
@@ -152,6 +153,9 @@ function createEngine(options) {
|
|
|
152
153
|
});
|
|
153
154
|
}
|
|
154
155
|
catch { }
|
|
156
|
+
// A sink-only logger has no file to buffer lines for.
|
|
157
|
+
if (logFileSource === undefined)
|
|
158
|
+
return;
|
|
155
159
|
buffer.push(line);
|
|
156
160
|
if (buffer.length >= 50)
|
|
157
161
|
flushLogs();
|
package/dist/logger/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type { CaptureSink, LogTestRecord } from './capture-sink.js';
|
|
2
2
|
export { createCaptureSink } from './capture-sink.js';
|
|
3
|
-
export type { ChannelLogger, InitLoggerOptions, Level, LoggerInstance, } from './engine.js';
|
|
3
|
+
export type { ChannelLogger, InitLoggerOptions, Level, LoggerInstance, LoggerOptions, SinkOnlyLoggerOptions, } from './engine.js';
|
|
4
4
|
export { createLogger, createLoggerInstance, flushForTest, flushLogs, initLogger, resetLoggerForTest, setLogLevel, } from './engine.js';
|
|
5
5
|
export type { RedactionOptions, Redactor } from './redact.js';
|
|
6
6
|
export { createRedactor, redact, redactStrings } from './redact.js';
|
package/dist/logger/redact.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
const SECRET_KEY_EXACT = /^(authorization|x-api-key|cookie|set-cookie|refresh|access|token)$/i;
|
|
2
2
|
const TOKEN_VALUE = /\b(Bearer\s+[\w.-]+|sk-[\w-]+|eyJ[\w.-]+)\b|ckh_[A-Za-z0-9_-]{20,}/g;
|
|
3
3
|
const MASK = '***REDACTED***';
|
|
4
|
+
// How many errors deep a cause chain is followed. A chain this long is
|
|
5
|
+
// already unreadable in a log line, and an unbounded one (each cause a fresh
|
|
6
|
+
// object, so the cycle check never trips) must not grow the line forever.
|
|
7
|
+
const MAX_ERROR_DEPTH = 8;
|
|
8
|
+
// Error's own fields are non-enumerable, so a plain entry walk renders an
|
|
9
|
+
// Error as `{}` and the name, message and stack a plugin logged are lost.
|
|
10
|
+
// The tag check also recognises an Error from another realm (a worker or a
|
|
11
|
+
// vm context), whose prototype is not this realm's Error.
|
|
12
|
+
function isError(value) {
|
|
13
|
+
return (value instanceof Error ||
|
|
14
|
+
Object.prototype.toString.call(value) === '[object Error]');
|
|
15
|
+
}
|
|
4
16
|
export function createRedactor(options = {}) {
|
|
5
17
|
const patterns = [TOKEN_VALUE, ...(options.extraValuePatterns ?? [])].map((pattern) => new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`));
|
|
6
18
|
function isSecretKey(key) {
|
|
@@ -12,7 +24,27 @@ export function createRedactor(options = {}) {
|
|
|
12
24
|
(normalized.endsWith('token') && !normalized.endsWith('tokens')) ||
|
|
13
25
|
!!options.extraSecretKeys?.(normalized));
|
|
14
26
|
}
|
|
15
|
-
|
|
27
|
+
// The fields every log reader wants first, then whatever else the error
|
|
28
|
+
// carries as its own enumerable properties (which is all an Error rendered
|
|
29
|
+
// as before). Every entry goes through the same key and string scrubbing as
|
|
30
|
+
// any other object, so a token in a message or stack is still masked.
|
|
31
|
+
function errorEntries(error) {
|
|
32
|
+
const fields = error;
|
|
33
|
+
const entries = [
|
|
34
|
+
['name', error.name],
|
|
35
|
+
['message', error.message],
|
|
36
|
+
['stack', error.stack],
|
|
37
|
+
];
|
|
38
|
+
for (const key of ['code', 'status', 'cause'])
|
|
39
|
+
if (fields[key] !== undefined)
|
|
40
|
+
entries.push([key, fields[key]]);
|
|
41
|
+
const listed = new Set(entries.map(([key]) => key));
|
|
42
|
+
for (const entry of Object.entries(error))
|
|
43
|
+
if (!listed.has(entry[0]))
|
|
44
|
+
entries.push(entry);
|
|
45
|
+
return entries;
|
|
46
|
+
}
|
|
47
|
+
function walk(value, keys, seen, errorDepth = 0) {
|
|
16
48
|
if (typeof value === 'string') {
|
|
17
49
|
return patterns.reduce((text, pattern) => text.replace(pattern, MASK), value);
|
|
18
50
|
}
|
|
@@ -23,11 +55,17 @@ export function createRedactor(options = {}) {
|
|
|
23
55
|
seen.add(value);
|
|
24
56
|
try {
|
|
25
57
|
if (Array.isArray(value))
|
|
26
|
-
return value.map((item) => walk(item, keys, seen));
|
|
58
|
+
return value.map((item) => walk(item, keys, seen, errorDepth));
|
|
59
|
+
const error = isError(value);
|
|
60
|
+
if (error && errorDepth >= MAX_ERROR_DEPTH)
|
|
61
|
+
return '[Truncated]';
|
|
62
|
+
const childDepth = error ? errorDepth + 1 : errorDepth;
|
|
27
63
|
// Define own properties so a diagnostic __proto__ key cannot change the output prototype.
|
|
28
|
-
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
64
|
+
return Object.fromEntries((error ? errorEntries(value) : Object.entries(value)).map(([key, item]) => [
|
|
29
65
|
key,
|
|
30
|
-
keys && isSecretKey(key)
|
|
66
|
+
keys && isSecretKey(key)
|
|
67
|
+
? MASK
|
|
68
|
+
: walk(item, keys, seen, childDepth),
|
|
31
69
|
]));
|
|
32
70
|
}
|
|
33
71
|
finally {
|
|
@@ -35,6 +35,24 @@ export interface NotificationScope {
|
|
|
35
35
|
* session, which plugins that drain from one process-wide TUI rely on.
|
|
36
36
|
*/
|
|
37
37
|
requireSession?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* What a drain with no session id returns, and what it acknowledges.
|
|
40
|
+
*
|
|
41
|
+
* - `'all'` (default): every queued notification, broadcasts and every
|
|
42
|
+
* session's targeted ones; a sessionless acknowledgement removes
|
|
43
|
+
* nothing. A plugin that drains from one process-wide TUI relies on this.
|
|
44
|
+
* - `'broadcast-only'`: broadcasts only (notifications pushed without a
|
|
45
|
+
* session), never a session's targeted ones, for a TUI that polls before
|
|
46
|
+
* it knows its session. A sessionless acknowledgement removes the
|
|
47
|
+
* acknowledged broadcasts and leaves every targeted notification queued.
|
|
48
|
+
* It also answers `isTuiConnected(scope, undefined)`: true while any
|
|
49
|
+
* drain on the queue, with or without a session, happened within the
|
|
50
|
+
* connection window.
|
|
51
|
+
*
|
|
52
|
+
* The option governs each call made with this scope; it does not split the
|
|
53
|
+
* queue, so a push through a scope without it lands in the same queue.
|
|
54
|
+
*/
|
|
55
|
+
sessionlessDrain?: 'all' | 'broadcast-only';
|
|
38
56
|
}
|
|
39
57
|
/** A strict notification scope was used without a session id. */
|
|
40
58
|
export declare class RpcSessionRequiredError extends Error {
|
|
@@ -45,5 +63,11 @@ export declare class RpcSessionRequiredError extends Error {
|
|
|
45
63
|
export declare function isSessionId(value: unknown): value is string;
|
|
46
64
|
export declare function pushNotification(scope: NotificationScope, payload: OpenDialogPayload, sessionId?: string): void;
|
|
47
65
|
export declare function drainNotifications(scope: NotificationScope, lastReceivedId?: number, sessionId?: string): RpcNotification[];
|
|
48
|
-
|
|
66
|
+
/**
|
|
67
|
+
* True when a TUI drained within the last 3000 ms. With a session id, that
|
|
68
|
+
* session drained. Without one, a broadcast-only scope answers whether any
|
|
69
|
+
* drain happened; a default scope answers false, since it only tracks
|
|
70
|
+
* sessions.
|
|
71
|
+
*/
|
|
72
|
+
export declare function isTuiConnected(scope: NotificationScope, sessionId: string | undefined): boolean;
|
|
49
73
|
export declare function resetNotificationsForTest(scope: NotificationScope): void;
|
|
@@ -21,7 +21,12 @@ function state(scope) {
|
|
|
21
21
|
]);
|
|
22
22
|
let value = queues.get(key);
|
|
23
23
|
if (!value) {
|
|
24
|
-
value = {
|
|
24
|
+
value = {
|
|
25
|
+
queue: [],
|
|
26
|
+
nextId: 1,
|
|
27
|
+
lastDrainAtBySession: new Map(),
|
|
28
|
+
lastDrainAtAny: 0,
|
|
29
|
+
};
|
|
25
30
|
queues.set(key, value);
|
|
26
31
|
}
|
|
27
32
|
return value;
|
|
@@ -49,25 +54,41 @@ export function drainNotifications(scope, lastReceivedId = 0, sessionId) {
|
|
|
49
54
|
throw new RpcSessionRequiredError('drain');
|
|
50
55
|
const value = state(scope);
|
|
51
56
|
const now = Date.now();
|
|
57
|
+
value.lastDrainAtAny = now;
|
|
52
58
|
if (sessionId !== undefined)
|
|
53
59
|
value.lastDrainAtBySession.set(sessionId, now);
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
n.sessionId ===
|
|
60
|
+
const broadcastOnly = scope.sessionlessDrain === 'broadcast-only';
|
|
61
|
+
const matches = (n) => sessionId === undefined
|
|
62
|
+
? !broadcastOnly || n.sessionId === undefined
|
|
63
|
+
: n.sessionId === undefined || n.sessionId === sessionId;
|
|
57
64
|
if (lastReceivedId > 0) {
|
|
58
65
|
value.queue = value.queue.filter((n) => {
|
|
59
66
|
if (n.id > lastReceivedId)
|
|
60
67
|
return true;
|
|
68
|
+
// A sessionless ack in broadcast-only mode consumes the broadcasts it
|
|
69
|
+
// was shown; in the default mode it consumes nothing, since it was
|
|
70
|
+
// shown targeted notifications other sessions still have to receive.
|
|
61
71
|
if (sessionId === undefined)
|
|
62
|
-
return
|
|
72
|
+
return !broadcastOnly || n.sessionId !== undefined;
|
|
63
73
|
return n.sessionId !== sessionId;
|
|
64
74
|
});
|
|
65
75
|
}
|
|
66
76
|
return value.queue.filter((n) => n.id > lastReceivedId && matches(n));
|
|
67
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* True when a TUI drained within the last 3000 ms. With a session id, that
|
|
80
|
+
* session drained. Without one, a broadcast-only scope answers whether any
|
|
81
|
+
* drain happened; a default scope answers false, since it only tracks
|
|
82
|
+
* sessions.
|
|
83
|
+
*/
|
|
68
84
|
export function isTuiConnected(scope, sessionId) {
|
|
69
85
|
const now = Date.now();
|
|
70
|
-
const
|
|
86
|
+
const value = state(scope);
|
|
87
|
+
const at = sessionId !== undefined
|
|
88
|
+
? (value.lastDrainAtBySession.get(sessionId) ?? 0)
|
|
89
|
+
: scope.sessionlessDrain === 'broadcast-only'
|
|
90
|
+
? value.lastDrainAtAny
|
|
91
|
+
: 0;
|
|
71
92
|
return at > 0 && now - at < TUI_CONNECTED_WINDOW_MS;
|
|
72
93
|
}
|
|
73
94
|
export function resetNotificationsForTest(scope) {
|
|
@@ -75,4 +96,5 @@ export function resetNotificationsForTest(scope) {
|
|
|
75
96
|
value.queue = [];
|
|
76
97
|
value.nextId = 1;
|
|
77
98
|
value.lastDrainAtBySession.clear();
|
|
99
|
+
value.lastDrainAtAny = 0;
|
|
78
100
|
}
|
package/dist/rpc/port-file.d.ts
CHANGED
|
@@ -17,4 +17,13 @@ export declare function writePortFile(dir: string, entry: {
|
|
|
17
17
|
beforeWrite?: () => void | Promise<void>;
|
|
18
18
|
}): Promise<string>;
|
|
19
19
|
export declare function sweepRpcState(root: string, activeDir: string, isManagedDir: (name: string) => boolean, log?: RpcLogChannel): Promise<void>;
|
|
20
|
-
export
|
|
20
|
+
export interface DiscoverPortFileOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Return only the expected PID's entry, or null; never fall back to
|
|
23
|
+
* another live server. Without an expected PID nothing matches, so the
|
|
24
|
+
* result is null. Off by default, when a missing or unmatched expected PID
|
|
25
|
+
* falls back to the newest live entry.
|
|
26
|
+
*/
|
|
27
|
+
exactPid?: boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare function discoverPortFile(dir: string, expectedPid?: number, options?: DiscoverPortFileOptions): Promise<PortFileEntry | null>;
|
package/dist/rpc/port-file.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { chmod, mkdir, readdir, readFile, rename, rmdir, unlink, writeFile, } from 'node:fs/promises';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* A PID a liveness probe may address. `process.kill(0 | negative, 0)` signals
|
|
6
|
+
* a process group rather than a process, and an unsafe integer cannot name a
|
|
7
|
+
* real process, so neither is ever probed.
|
|
8
|
+
*/
|
|
9
|
+
function isProbeablePid(pid) {
|
|
10
|
+
return typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0;
|
|
11
|
+
}
|
|
4
12
|
function pidAlive(pid) {
|
|
13
|
+
if (!isProbeablePid(pid))
|
|
14
|
+
return false;
|
|
5
15
|
try {
|
|
6
16
|
process.kill(pid, 0);
|
|
7
17
|
return true;
|
|
@@ -23,14 +33,29 @@ export function getRpcDir(rpcRoot, directoryPrefix, projectDirectory) {
|
|
|
23
33
|
return join(rpcRoot, directoryPrefix +
|
|
24
34
|
createHash('sha256').update(projectDirectory).digest('hex').slice(0, 16));
|
|
25
35
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
/** The PID a port file's name claims, or undefined for a malformed name. */
|
|
37
|
+
function filenamePid(name) {
|
|
38
|
+
const match = /^port-(\d+)\.json$/.exec(name);
|
|
39
|
+
return match ? Number(match[1]) : undefined;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* An entry a client may connect to: its PID is probeable and matches the PID
|
|
43
|
+
* in its file name (a mismatch means the file was not written by the server
|
|
44
|
+
* it names), its port is a real TCP port, and its token is non-empty (an
|
|
45
|
+
* absent token would otherwise be sent as `Bearer undefined`).
|
|
46
|
+
*/
|
|
47
|
+
function isUsablePortFileEntry(value, name) {
|
|
48
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
49
|
+
return false;
|
|
50
|
+
const { pid, port, token } = value;
|
|
51
|
+
return (isProbeablePid(pid) &&
|
|
52
|
+
filenamePid(name) === pid &&
|
|
53
|
+
typeof port === 'number' &&
|
|
54
|
+
Number.isInteger(port) &&
|
|
55
|
+
port >= 1 &&
|
|
56
|
+
port <= 65_535 &&
|
|
57
|
+
typeof token === 'string' &&
|
|
58
|
+
token.length > 0);
|
|
34
59
|
}
|
|
35
60
|
async function removeCorruptPortFile(portFile, log) {
|
|
36
61
|
log?.debug('rpc corrupt port file', { pid: process.pid, portFile });
|
|
@@ -53,11 +78,18 @@ export async function writePortFile(dir, entry, options = {}) {
|
|
|
53
78
|
const full = { ...entry, startedAt: Date.now() };
|
|
54
79
|
const target = join(dir, `port-${entry.pid}.json`);
|
|
55
80
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
81
|
+
try {
|
|
82
|
+
await writeFile(tmp, JSON.stringify(full), {
|
|
83
|
+
encoding: 'utf8',
|
|
84
|
+
mode: 0o600,
|
|
85
|
+
});
|
|
86
|
+
await rename(tmp, target);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
// The staged file carries the server token; never leave it behind.
|
|
90
|
+
await unlink(tmp).catch(() => { });
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
61
93
|
return target;
|
|
62
94
|
};
|
|
63
95
|
try {
|
|
@@ -116,7 +148,7 @@ export async function sweepRpcState(root, activeDir, isManagedDir, log) {
|
|
|
116
148
|
await removeCorruptPortFile(portFile, log);
|
|
117
149
|
continue;
|
|
118
150
|
}
|
|
119
|
-
if (!isUsablePortFileEntry(parsed)) {
|
|
151
|
+
if (!isUsablePortFileEntry(parsed, name)) {
|
|
120
152
|
await removeCorruptPortFile(portFile, log);
|
|
121
153
|
continue;
|
|
122
154
|
}
|
|
@@ -128,7 +160,7 @@ export async function sweepRpcState(root, activeDir, isManagedDir, log) {
|
|
|
128
160
|
await rmdir(dir).catch(() => { });
|
|
129
161
|
}
|
|
130
162
|
}
|
|
131
|
-
export async function discoverPortFile(dir, expectedPid) {
|
|
163
|
+
export async function discoverPortFile(dir, expectedPid, options = {}) {
|
|
132
164
|
let names;
|
|
133
165
|
try {
|
|
134
166
|
names = await readdir(dir);
|
|
@@ -142,7 +174,7 @@ export async function discoverPortFile(dir, expectedPid) {
|
|
|
142
174
|
continue;
|
|
143
175
|
try {
|
|
144
176
|
const parsed = JSON.parse(await readFile(join(dir, name), 'utf8'));
|
|
145
|
-
if (isUsablePortFileEntry(parsed)) {
|
|
177
|
+
if (isUsablePortFileEntry(parsed, name)) {
|
|
146
178
|
if (pidAlive(parsed.pid))
|
|
147
179
|
live.push(parsed);
|
|
148
180
|
else
|
|
@@ -156,6 +188,8 @@ export async function discoverPortFile(dir, expectedPid) {
|
|
|
156
188
|
const candidates = expectedPid !== undefined && expectedPid >= 1
|
|
157
189
|
? live.filter((entry) => entry.pid === expectedPid)
|
|
158
190
|
: [];
|
|
191
|
+
if (options.exactPid === true && candidates.length === 0)
|
|
192
|
+
return null;
|
|
159
193
|
const entries = candidates.length > 0 ? candidates : live;
|
|
160
194
|
const sortTime = (entry) => typeof entry.startedAt === 'number' && Number.isFinite(entry.startedAt)
|
|
161
195
|
? entry.startedAt
|
package/dist/rpc/rpc-client.d.ts
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import type { ApplyRequest, ApplyResult, RpcNotification } from './notifications.js';
|
|
2
|
-
import { type PortFileEntry } from './port-file.js';
|
|
2
|
+
import { type DiscoverPortFileOptions, type PortFileEntry } from './port-file.js';
|
|
3
3
|
export interface RpcClient {
|
|
4
|
-
pending: (lastReceivedId: number, sessionId?: string) => Promise<RpcNotification[]>;
|
|
4
|
+
pending: (lastReceivedId: number, sessionId?: string, timeoutMs?: number) => Promise<RpcNotification[]>;
|
|
5
5
|
apply: (request: ApplyRequest, timeoutMs?: number) => Promise<ApplyResult>;
|
|
6
6
|
}
|
|
7
7
|
export declare const DEFAULT_RPC_TIMEOUT_MS = 2000;
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* `exactPid`: every call goes only to the expected PID's server. With no
|
|
10
|
+
* such server (or no expected PID) a call returns its fallback without
|
|
11
|
+
* opening a socket, on every call. Off by default, when a call falls back to
|
|
12
|
+
* the newest live server.
|
|
13
|
+
*/
|
|
14
|
+
export type RpcClientOptions = DiscoverPortFileOptions;
|
|
15
|
+
/**
|
|
16
|
+
* A client for the server in `dir`, preferring `expectedPid`'s.
|
|
17
|
+
*
|
|
18
|
+
* `onSelected` reports the first selection: it is told which entry (or null)
|
|
19
|
+
* discovery chose, until one call of it returns normally. It is a report, not
|
|
20
|
+
* a gate: an observer that throws rejects that call before any request is
|
|
21
|
+
* sent and is asked again on the next call, but nothing stops a later call
|
|
22
|
+
* once an observer has returned. A caller that must never reach another
|
|
23
|
+
* server passes `{ exactPid: true }`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createRpcClient(dir: string, expectedPid?: number, onSelected?: (entry: PortFileEntry | null) => void, options?: RpcClientOptions): RpcClient;
|
package/dist/rpc/rpc-client.js
CHANGED
|
@@ -1,48 +1,150 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { connect } from 'node:net';
|
|
2
|
+
import { discoverPortFile, } from './port-file.js';
|
|
2
3
|
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);
|
|
4
|
+
async function call(dir, expectedPid, discoverOptions, onSelected, method, params, timeoutMs = DEFAULT_RPC_TIMEOUT_MS) {
|
|
5
|
+
const entry = await discoverPortFile(dir, expectedPid, discoverOptions);
|
|
5
6
|
onSelected?.(entry);
|
|
6
7
|
if (!entry)
|
|
7
8
|
return null;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
9
|
+
// A raw loopback socket never consults runtime HTTP proxy settings, which
|
|
10
|
+
// otherwise can expose the bearer token to a configured proxy. timeoutMs is
|
|
11
|
+
// a total deadline for connect, request and the full response, not idle time.
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let socket;
|
|
14
|
+
let settled = false;
|
|
15
|
+
const done = (value) => {
|
|
16
|
+
if (settled)
|
|
17
|
+
return;
|
|
18
|
+
settled = true;
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
socket?.destroy();
|
|
21
|
+
resolve(value);
|
|
22
|
+
};
|
|
23
|
+
const timer = setTimeout(() => done(null), timeoutMs);
|
|
24
|
+
try {
|
|
25
|
+
const body = JSON.stringify(params);
|
|
26
|
+
socket = connect({ host: '127.0.0.1', port: entry.port });
|
|
27
|
+
socket.on('connect', () => {
|
|
28
|
+
// HTTP/1.0 avoids chunked responses; explicitly request connection close
|
|
29
|
+
// because older Bun servers can keep delayed HTTP/1.0 replies open.
|
|
30
|
+
socket?.write(`POST /rpc/${method} HTTP/1.0\r\n` +
|
|
31
|
+
`Host: 127.0.0.1:${entry.port}\r\n` +
|
|
32
|
+
'Connection: close\r\n' +
|
|
33
|
+
'Content-Type: application/json\r\n' +
|
|
34
|
+
`Content-Length: ${Buffer.byteLength(body)}\r\n` +
|
|
35
|
+
`Authorization: Bearer ${entry.token}\r\n\r\n` +
|
|
36
|
+
body);
|
|
37
|
+
});
|
|
38
|
+
const chunks = [];
|
|
39
|
+
let bytes = 0;
|
|
40
|
+
let headerBytes;
|
|
41
|
+
let contentLength;
|
|
42
|
+
let prefix = Buffer.alloc(0);
|
|
43
|
+
const finishBody = () => {
|
|
44
|
+
if (headerBytes === undefined)
|
|
45
|
+
return done(null);
|
|
46
|
+
try {
|
|
47
|
+
const payload = Buffer.concat(chunks).subarray(headerBytes);
|
|
48
|
+
done(JSON.parse(payload.toString('utf8')));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
done(null);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
socket.on('data', (chunk) => {
|
|
55
|
+
if (settled)
|
|
56
|
+
return;
|
|
57
|
+
bytes += chunk.length;
|
|
58
|
+
chunks.push(chunk);
|
|
59
|
+
if (headerBytes === undefined) {
|
|
60
|
+
prefix = Buffer.concat([prefix, chunk]);
|
|
61
|
+
const separator = prefix.indexOf('\r\n\r\n');
|
|
62
|
+
if (separator >= 0)
|
|
63
|
+
headerBytes = separator + 4;
|
|
64
|
+
if ((headerBytes ?? prefix.length) > 16 * 1024)
|
|
65
|
+
return done(null);
|
|
66
|
+
if (headerBytes !== undefined) {
|
|
67
|
+
const headers = prefix
|
|
68
|
+
.subarray(0, separator)
|
|
69
|
+
.toString('latin1')
|
|
70
|
+
.split('\r\n');
|
|
71
|
+
const status = /^HTTP\/1\.[01] (\d{3})(?: |$)/.exec(headers[0] ?? '');
|
|
72
|
+
if (!status || Number(status[1]) < 200 || Number(status[1]) >= 300)
|
|
73
|
+
return done(null);
|
|
74
|
+
for (const header of headers.slice(1)) {
|
|
75
|
+
const colon = header.indexOf(':');
|
|
76
|
+
const name = header.slice(0, colon).toLowerCase();
|
|
77
|
+
const value = header.slice(colon + 1).trim();
|
|
78
|
+
if (name === 'transfer-encoding')
|
|
79
|
+
return done(null);
|
|
80
|
+
if (name === 'content-length') {
|
|
81
|
+
if (contentLength !== undefined || !/^\d+$/.test(value))
|
|
82
|
+
return done(null);
|
|
83
|
+
contentLength = Number(value);
|
|
84
|
+
if (!Number.isSafeInteger(contentLength) ||
|
|
85
|
+
contentLength > 8 * 1024 * 1024)
|
|
86
|
+
return done(null);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
prefix = Buffer.alloc(0);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const bodyBytes = bytes - (headerBytes ?? bytes);
|
|
93
|
+
if (bodyBytes > 8 * 1024 * 1024)
|
|
94
|
+
return done(null);
|
|
95
|
+
if (contentLength !== undefined) {
|
|
96
|
+
if (bodyBytes > contentLength)
|
|
97
|
+
return done(null);
|
|
98
|
+
// Bun 1.3.14 can keep an async HTTP/1.0 reply open after sending
|
|
99
|
+
// its complete Content-Length body; don't wait for EOF in that case.
|
|
100
|
+
if (bodyBytes === contentLength)
|
|
101
|
+
finishBody();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
socket.on('end', () => {
|
|
105
|
+
if (contentLength !== undefined &&
|
|
106
|
+
bytes - (headerBytes ?? 0) !== contentLength)
|
|
107
|
+
return done(null);
|
|
108
|
+
finishBody();
|
|
109
|
+
});
|
|
110
|
+
socket.on('error', () => done(null));
|
|
111
|
+
socket.on('close', () => done(null));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
done(null);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
30
117
|
}
|
|
31
|
-
|
|
118
|
+
/**
|
|
119
|
+
* A client for the server in `dir`, preferring `expectedPid`'s.
|
|
120
|
+
*
|
|
121
|
+
* `onSelected` reports the first selection: it is told which entry (or null)
|
|
122
|
+
* discovery chose, until one call of it returns normally. It is a report, not
|
|
123
|
+
* a gate: an observer that throws rejects that call before any request is
|
|
124
|
+
* sent and is asked again on the next call, but nothing stops a later call
|
|
125
|
+
* once an observer has returned. A caller that must never reach another
|
|
126
|
+
* server passes `{ exactPid: true }`.
|
|
127
|
+
*/
|
|
128
|
+
export function createRpcClient(dir, expectedPid, onSelected, options = {}) {
|
|
129
|
+
const discoverOptions = {
|
|
130
|
+
exactPid: options.exactPid,
|
|
131
|
+
};
|
|
32
132
|
let reportedSelection = false;
|
|
33
133
|
const reportSelected = (entry) => {
|
|
34
134
|
if (reportedSelection)
|
|
35
135
|
return;
|
|
36
|
-
reportedSelection = true;
|
|
37
136
|
onSelected?.(entry);
|
|
137
|
+
// Counted only after the observer returns, so a throwing one is not
|
|
138
|
+
// silently skipped on later calls.
|
|
139
|
+
reportedSelection = true;
|
|
38
140
|
};
|
|
39
141
|
return {
|
|
40
|
-
async pending(lastReceivedId, sessionId) {
|
|
41
|
-
const out = await call(dir, expectedPid, reportSelected, 'pending-notifications', { lastReceivedId, sessionId });
|
|
142
|
+
async pending(lastReceivedId, sessionId, timeoutMs) {
|
|
143
|
+
const out = await call(dir, expectedPid, discoverOptions, reportSelected, 'pending-notifications', { lastReceivedId, sessionId }, timeoutMs);
|
|
42
144
|
return out?.messages ?? [];
|
|
43
145
|
},
|
|
44
146
|
async apply(request, timeoutMs) {
|
|
45
|
-
const out = await call(dir, expectedPid, reportSelected, 'apply', {
|
|
147
|
+
const out = await call(dir, expectedPid, discoverOptions, reportSelected, 'apply', {
|
|
46
148
|
...request,
|
|
47
149
|
}, timeoutMs);
|
|
48
150
|
return out ?? { text: 'apply failed', knobs: {} };
|
package/dist/rpc/rpc-server.d.ts
CHANGED
|
@@ -23,5 +23,23 @@ export interface RpcServerOptions {
|
|
|
23
23
|
requireSession?: boolean;
|
|
24
24
|
timeoutMs?: number;
|
|
25
25
|
receiptTimeoutMs?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Answer an `apply` call whose handler is still running after this many
|
|
28
|
+
* milliseconds with 504 `{error: 'handler deadline exceeded'}`. The handler
|
|
29
|
+
* is not cancelled; its eventual result is discarded. Unset by default,
|
|
30
|
+
* when only the socket inactivity timeout (`timeoutMs`) bounds a handler,
|
|
31
|
+
* by destroying the socket.
|
|
32
|
+
*/
|
|
33
|
+
applyDeadlineMs?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Thrown by an `apply` or `drain` handler to refuse a request with a 4xx
|
|
37
|
+
* status. Its message is sent on the wire as `{error: message}`, so it must
|
|
38
|
+
* be written for the client and never quote a credential. Any other error a
|
|
39
|
+
* handler throws answers 500 with a fixed code.
|
|
40
|
+
*/
|
|
41
|
+
export declare class RpcRequestError extends Error {
|
|
42
|
+
readonly status: number;
|
|
43
|
+
constructor(status: number, message: string);
|
|
26
44
|
}
|
|
27
45
|
export declare function startRpcServer(options: RpcServerOptions): Promise<RpcServerHandle>;
|