@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.
@@ -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: InitLoggerOptions): void;
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: InitLoggerOptions): LoggerInstance;
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: InitLoggerOptions): LoggerInstance;
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
@@ -126,7 +126,8 @@ function createEngine(options) {
126
126
  }, 500);
127
127
  }
128
128
  function emit(channel, level, message, data) {
129
- if (logFileSource === undefined)
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();
@@ -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';
@@ -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
- function walk(value, keys, seen) {
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) ? MASK : walk(item, keys, seen),
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
- export declare function isTuiConnected(scope: NotificationScope, sessionId: string): boolean;
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 = { queue: [], nextId: 1, lastDrainAtBySession: new Map() };
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 matches = (n) => sessionId === undefined ||
55
- n.sessionId === undefined ||
56
- n.sessionId === 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 true;
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 at = state(scope).lastDrainAtBySession.get(sessionId) ?? 0;
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
  }
@@ -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 declare function discoverPortFile(dir: string, expectedPid?: number): Promise<PortFileEntry | null>;
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>;
@@ -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
- 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));
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
- await writeFile(tmp, JSON.stringify(full), {
57
- encoding: 'utf8',
58
- mode: 0o600,
59
- });
60
- await rename(tmp, target);
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
@@ -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
- export declare function createRpcClient(dir: string, expectedPid?: number, onSelected?: (entry: PortFileEntry | null) => void): RpcClient;
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;
@@ -1,48 +1,150 @@
1
- import { discoverPortFile } from './port-file.js';
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
- 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
- }
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
- export function createRpcClient(dir, expectedPid, onSelected) {
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: {} };
@@ -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>;