@promptctl/cc-candybar 1.0.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 +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- package/src/var-system/types.ts +57 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// XDG Base Directory split:
|
|
6
|
+
// - daemon runtime (pid, log, heap snapshots, spawn.lock) → $XDG_STATE_HOME/cc-candybar
|
|
7
|
+
// - filesystem caches (git, usage, last-render) → $XDG_CACHE_HOME/cc-candybar
|
|
8
|
+
//
|
|
9
|
+
// Both default per the XDG spec ($HOME/.local/state and $HOME/.cache). Empty
|
|
10
|
+
// env vars fall through to the defaults. The two roots are kept separate so
|
|
11
|
+
// users can `rm -rf` either one without taking the other down.
|
|
12
|
+
//
|
|
13
|
+
// The socket path is NOT derived from XDG_STATE_HOME — see socketPath() below.
|
|
14
|
+
// The Rust client mirrors both path families in rust-client/src/main.rs; both
|
|
15
|
+
// must agree or the client can't find the daemon's socket.
|
|
16
|
+
|
|
17
|
+
function xdgEnv(name: string): string | undefined {
|
|
18
|
+
const v = process.env[name];
|
|
19
|
+
return v && v.length > 0 ? v : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function stateDir(): string {
|
|
23
|
+
const base =
|
|
24
|
+
xdgEnv("XDG_STATE_HOME") ?? path.join(os.homedir(), ".local", "state");
|
|
25
|
+
return path.join(base, "cc-candybar");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function cacheDir(): string {
|
|
29
|
+
const base = xdgEnv("XDG_CACHE_HOME") ?? path.join(os.homedir(), ".cache");
|
|
30
|
+
return path.join(base, "cc-candybar");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function configDir(): string {
|
|
34
|
+
const base = xdgEnv("XDG_CONFIG_HOME") ?? path.join(os.homedir(), ".config");
|
|
35
|
+
return path.join(base, "cc-candybar");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// `daemonDir` kept as the canonical name for the runtime root so existing
|
|
39
|
+
// callers (limits.ts, server.ts) don't need to learn a new term. It now
|
|
40
|
+
// resolves under $XDG_STATE_HOME/cc-candybar instead of ~/.claude/powerline.
|
|
41
|
+
export function daemonDir(): string {
|
|
42
|
+
return stateDir();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// [LAW:one-source-of-truth] The socket IS the daemon's identity — same as
|
|
46
|
+
// tmux's /tmp/tmux-<uid>/default model. UID is kernel identity: immutable,
|
|
47
|
+
// not overridable by any env var. /tmp is guaranteed on every Unix host and
|
|
48
|
+
// is cleared on reboot, which is fine — the daemon doesn't survive reboots.
|
|
49
|
+
// CC_CANDYBAR_SOCKET is the only explicit override for intentional isolation
|
|
50
|
+
// (tests, dev, multiple intentional instances).
|
|
51
|
+
export function socketPath(): string {
|
|
52
|
+
const override = process.env.CC_CANDYBAR_SOCKET;
|
|
53
|
+
if (override) return override;
|
|
54
|
+
const uid = os.userInfo().uid;
|
|
55
|
+
return path.join("/tmp", `cc-candybar-${uid}`, "socket");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// [LAW:single-enforcer] The daemon is the sole creator of the socket parent
|
|
59
|
+
// directory. If we enforce "this dir is uid==me + mode 0700 + not a symlink"
|
|
60
|
+
// at bind time, then by induction every successful bind happened under a
|
|
61
|
+
// trusted parent — and any client reaching the socket via the canonical path
|
|
62
|
+
// reached one our daemon owns. A foreign-uid or world-writable squat triggers
|
|
63
|
+
// a refusal, turning a silent-MITM attempt into a visible daemon failure (the
|
|
64
|
+
// client sees no response, the user sees the last cached render).
|
|
65
|
+
//
|
|
66
|
+
// Throws on any unsafe state; callers are expected to let the daemon exit.
|
|
67
|
+
// [LAW:no-silent-fallbacks] do NOT auto-rmdir + recreate — a wrong-owner dir
|
|
68
|
+
// is hostile state, not a recoverable error.
|
|
69
|
+
export function ensureSocketParentSafe(sockPath: string): void {
|
|
70
|
+
const parent = path.dirname(sockPath);
|
|
71
|
+
// mkdir with mode 0o700; harmless if already exists (mode is not applied
|
|
72
|
+
// post-hoc — we verify it next).
|
|
73
|
+
fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
74
|
+
|
|
75
|
+
const st = fs.lstatSync(parent);
|
|
76
|
+
if (st.isSymbolicLink()) {
|
|
77
|
+
throw new Error(`socket parent is a symlink: ${parent}`);
|
|
78
|
+
}
|
|
79
|
+
if (!st.isDirectory()) {
|
|
80
|
+
throw new Error(`socket parent is not a directory: ${parent}`);
|
|
81
|
+
}
|
|
82
|
+
const myUid = os.userInfo().uid;
|
|
83
|
+
// getuid is undefined on Windows; we don't ship there, but guard cheaply.
|
|
84
|
+
if (typeof myUid === "number" && st.uid !== myUid) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`socket parent is not owned by uid ${myUid}: ${parent} (owner uid=${st.uid})`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
// Reject any group/world bits — only the owner may traverse.
|
|
90
|
+
if ((st.mode & 0o077) !== 0) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`socket parent has unsafe permissions: ${parent} (mode=${(st.mode & 0o777).toString(8)}, expected 0700)`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
// If a stale socket file is a symlink, refuse — an attacker who briefly
|
|
96
|
+
// had write access to a previously-permissive dir could have planted a
|
|
97
|
+
// symlink even after we tighten perms.
|
|
98
|
+
try {
|
|
99
|
+
const sst = fs.lstatSync(sockPath);
|
|
100
|
+
if (sst.isSymbolicLink()) {
|
|
101
|
+
throw new Error(`socket path is a symlink: ${sockPath}`);
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
105
|
+
if (code !== "ENOENT") throw e;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function pidPath(): string {
|
|
110
|
+
return path.join(stateDir(), "pid");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function sessionStatePath(): string {
|
|
114
|
+
return path.join(stateDir(), "session-state.json");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// [LAW:single-enforcer] Caller-side spawn dedup. Held by a client *only* during
|
|
118
|
+
// the spawn window — never for the daemon's lifetime. The actual one-daemon
|
|
119
|
+
// invariant is enforced by atomic bind() on socketPath() inside the daemon.
|
|
120
|
+
// This file is a thundering-herd optimization, not the load-bearing lock.
|
|
121
|
+
export function spawnLockPath(): string {
|
|
122
|
+
return path.join(stateDir(), "spawn.lock");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function logPath(): string {
|
|
126
|
+
return path.join(stateDir(), "daemon.log");
|
|
127
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type { Socket } from "node:net";
|
|
2
|
+
import type { ClaudeHookData } from "../utils/claude";
|
|
3
|
+
import type { StatsSnapshot } from "./stats";
|
|
4
|
+
import type { DebugSnapshot, DebugWhat } from "./debug-types";
|
|
5
|
+
|
|
6
|
+
// [LAW:types-are-the-program] PROTOCOL_VERSION encodes one thing:
|
|
7
|
+
// "old-client × new-daemon (or vice versa) cannot communicate." It moves on
|
|
8
|
+
// BREAKING changes only — never on additive ones. The two cases are
|
|
9
|
+
// genuinely different theorems and the version field carries the stronger:
|
|
10
|
+
// incompatibility, not growth.
|
|
11
|
+
//
|
|
12
|
+
// **Additive** (no bump):
|
|
13
|
+
// - Adding a new request `kind`. Old daemons reject the unknown kind via
|
|
14
|
+
// the existing BAD_REQUEST fallthrough; old clients never send it.
|
|
15
|
+
// - Adding a new optional field that older parsers ignore safely.
|
|
16
|
+
// - Adding a new response variant produced only in response to a new kind.
|
|
17
|
+
//
|
|
18
|
+
// **Breaking** (bump):
|
|
19
|
+
// - Changing the semantics or required shape of an existing kind.
|
|
20
|
+
// - Removing a kind or field old clients depend on.
|
|
21
|
+
// - Renaming a wire field.
|
|
22
|
+
//
|
|
23
|
+
// The 452-corpse precedent (kz8.5) makes this discipline load-bearing: every
|
|
24
|
+
// bump forces every running statusbar through VERSION_MISMATCH until its
|
|
25
|
+
// session restarts, because the spiral-breaker contract refuses to kick on a
|
|
26
|
+
// permanent error. A bump for an additive change taxes every user with
|
|
27
|
+
// blank-statusbar minutes for a feature their session doesn't even use.
|
|
28
|
+
// Don't bump for growth.
|
|
29
|
+
export const PROTOCOL_VERSION = 3;
|
|
30
|
+
|
|
31
|
+
export interface RenderRequest {
|
|
32
|
+
v: number;
|
|
33
|
+
kind: "render";
|
|
34
|
+
hookData: ClaudeHookData;
|
|
35
|
+
args: string[];
|
|
36
|
+
cwd: string;
|
|
37
|
+
// [LAW:single-enforcer] Terminal width is captured at the trust boundary
|
|
38
|
+
// (the client's env, where COLUMNS/ioctl are meaningful) and trusted by the
|
|
39
|
+
// daemon. Absence means the client couldn't determine it. The wire field is
|
|
40
|
+
// typed `number` but the wire is untrusted JSON — callers MUST run it
|
|
41
|
+
// through sanitizeTermCols at the receive boundary before using it.
|
|
42
|
+
termCols?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// [LAW:no-defensive-null-guards] exception: trust boundary. The wire is
|
|
46
|
+
// untrusted JSON; downstream code treats termCols as an integer in a sane
|
|
47
|
+
// range. Validate once here so the type's promise is true.
|
|
48
|
+
//
|
|
49
|
+
// Pathologically large values are capped (not rejected) so a future
|
|
50
|
+
// genuinely-huge terminal still renders — 10000 is two orders of magnitude
|
|
51
|
+
// above the largest plausible real terminal.
|
|
52
|
+
const MAX_TERM_COLS = 10000;
|
|
53
|
+
export function sanitizeTermCols(v: unknown): number | undefined {
|
|
54
|
+
if (typeof v !== "number") return undefined;
|
|
55
|
+
if (!Number.isFinite(v)) return undefined;
|
|
56
|
+
const n = Math.floor(v);
|
|
57
|
+
if (n <= 0) return undefined;
|
|
58
|
+
return n > MAX_TERM_COLS ? MAX_TERM_COLS : n;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ShutdownRequest {
|
|
62
|
+
v: number;
|
|
63
|
+
kind: "shutdown";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ClickRequest {
|
|
67
|
+
v: number;
|
|
68
|
+
kind: "click";
|
|
69
|
+
verb: string;
|
|
70
|
+
value: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface StatsRequest {
|
|
74
|
+
v: number;
|
|
75
|
+
kind: "stats";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// [LAW:types-are-the-program] The `what` discriminator carries the response
|
|
79
|
+
// shape forward — a client requesting "vars" gets vars data, "segments" gets
|
|
80
|
+
// segments data, "config" gets config data. No string-keyed lookup on the
|
|
81
|
+
// client side; route by structure. See DebugSnapshot in ./debug-types.
|
|
82
|
+
export interface DebugRequest {
|
|
83
|
+
v: number;
|
|
84
|
+
kind: "debug";
|
|
85
|
+
what: DebugWhat;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type Request =
|
|
89
|
+
| RenderRequest
|
|
90
|
+
| ShutdownRequest
|
|
91
|
+
| StatsRequest
|
|
92
|
+
| ClickRequest
|
|
93
|
+
| DebugRequest;
|
|
94
|
+
|
|
95
|
+
export type Response =
|
|
96
|
+
| { ok: true; output: string }
|
|
97
|
+
| { ok: true; stats: StatsSnapshot }
|
|
98
|
+
// [LAW:types-are-the-program] DebugSnapshot is itself a discriminated union
|
|
99
|
+
// on `what`, so the response type carries the requested kind through to the
|
|
100
|
+
// client. A `{ ok: true; debug: { what: "vars"; vars: [...] } }` shape is
|
|
101
|
+
// self-describing — the client doesn't need to remember which `what` it sent.
|
|
102
|
+
| { ok: true; debug: DebugSnapshot }
|
|
103
|
+
// [LAW:types-are-the-program] `daemonV` is the daemon's own
|
|
104
|
+
// PROTOCOL_VERSION, echoed on every error response so the client can render
|
|
105
|
+
// a meaningful diagnostic on VERSION_MISMATCH without parsing the human
|
|
106
|
+
// message. Optional for back-compat: older daemons (or test stubs) that
|
|
107
|
+
// omit it are still parseable by current clients.
|
|
108
|
+
| { ok: false; error: string; code: ErrorCode; daemonV?: number };
|
|
109
|
+
|
|
110
|
+
// [LAW:types-are-the-program] The wire-level discriminator splits failures
|
|
111
|
+
// into two recovery classes: TIMEOUT is transient — the daemon is alive but
|
|
112
|
+
// slow, so a respawn or retry has a real chance of recovering. Every other
|
|
113
|
+
// code is permanent — respawning would hit the same response identically
|
|
114
|
+
// (the daemon refuses the request for VERSION_MISMATCH or BAD_REQUEST, or
|
|
115
|
+
// fails internally for RENDER_FAILED in a way the spawn loop cannot cure),
|
|
116
|
+
// so the spiral-breaker contract requires the client NOT to kick. The
|
|
117
|
+
// kick-vs-no-kick decision is encoded off this code, not off the error
|
|
118
|
+
// string.
|
|
119
|
+
export type ErrorCode =
|
|
120
|
+
| "VERSION_MISMATCH"
|
|
121
|
+
| "TIMEOUT"
|
|
122
|
+
| "RENDER_FAILED"
|
|
123
|
+
| "BAD_REQUEST";
|
|
124
|
+
|
|
125
|
+
// [LAW:types-are-the-program] A typed error class for failures that originate
|
|
126
|
+
// inside the wire-protocol layer (oversized frame, JSON decode failure).
|
|
127
|
+
// Callers in src/daemon/client-transport.ts branch on `e instanceof ProtocolError`
|
|
128
|
+
// to classify these as `permanent/malformed_response` — far more robust than
|
|
129
|
+
// substring-matching against the message, which would silently drift if
|
|
130
|
+
// Node's JSON.parse error wording changes. The class is small but
|
|
131
|
+
// load-bearing: it makes the kick-vs-show-error decision a structural
|
|
132
|
+
// property of the thrown value, not a property of its english string.
|
|
133
|
+
export class ProtocolError extends Error {
|
|
134
|
+
constructor(message: string) {
|
|
135
|
+
super(message);
|
|
136
|
+
this.name = "ProtocolError";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 4-byte big-endian length prefix + UTF-8 JSON body. Length-prefix beats
|
|
141
|
+
// newline-delimited because error messages may contain embedded newlines and
|
|
142
|
+
// we'd rather not parse them out of-band.
|
|
143
|
+
//
|
|
144
|
+
// [LAW:one-source-of-truth] FRAME_HEADER_BYTES and MAX_FRAME_BYTES are part
|
|
145
|
+
// of the wire contract the Rust client mirrors (rust-client/src/main.rs);
|
|
146
|
+
// scripts/check-protocol.mjs diffs them, so they must stay named consts.
|
|
147
|
+
export const FRAME_HEADER_BYTES = 4;
|
|
148
|
+
export const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
|
149
|
+
|
|
150
|
+
export function encodeFrame(value: unknown): Buffer {
|
|
151
|
+
const body = Buffer.from(JSON.stringify(value), "utf8");
|
|
152
|
+
const header = Buffer.alloc(FRAME_HEADER_BYTES);
|
|
153
|
+
header.writeUInt32BE(body.length, 0);
|
|
154
|
+
return Buffer.concat([header, body]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Streaming frame reader. Calls `onFrame` for each complete frame. Caller
|
|
158
|
+
// owns lifecycle — call `feed` with each chunk; reader keeps a buffer.
|
|
159
|
+
export function makeFrameReader(
|
|
160
|
+
onFrame: (frame: unknown) => void,
|
|
161
|
+
onError: (err: Error) => void,
|
|
162
|
+
) {
|
|
163
|
+
let buf = Buffer.alloc(0);
|
|
164
|
+
return function feed(chunk: Buffer): void {
|
|
165
|
+
buf = Buffer.concat([buf, chunk]);
|
|
166
|
+
while (buf.length >= FRAME_HEADER_BYTES) {
|
|
167
|
+
const len = buf.readUInt32BE(0);
|
|
168
|
+
// Hard cap to defend against a runaway sender allocating gigabytes.
|
|
169
|
+
// [LAW:types-are-the-program] ProtocolError carries the discriminator
|
|
170
|
+
// structurally — interpretException routes on `instanceof`, not on a
|
|
171
|
+
// brittle string match against the message body.
|
|
172
|
+
if (len > MAX_FRAME_BYTES) {
|
|
173
|
+
onError(new ProtocolError(`frame too large: ${len}`));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (buf.length < FRAME_HEADER_BYTES + len) return;
|
|
177
|
+
const body = buf.subarray(FRAME_HEADER_BYTES, FRAME_HEADER_BYTES + len);
|
|
178
|
+
buf = buf.subarray(FRAME_HEADER_BYTES + len);
|
|
179
|
+
try {
|
|
180
|
+
onFrame(JSON.parse(body.toString("utf8")));
|
|
181
|
+
} catch (e) {
|
|
182
|
+
// JSON.parse throws SyntaxError; wrap as ProtocolError so the
|
|
183
|
+
// recovery class is structurally typed (not message-matched).
|
|
184
|
+
// Preserve the original cause for diagnostic logging.
|
|
185
|
+
const wrapped = new ProtocolError(
|
|
186
|
+
e instanceof Error ? e.message : String(e),
|
|
187
|
+
);
|
|
188
|
+
if (e instanceof Error) {
|
|
189
|
+
wrapped.cause = e;
|
|
190
|
+
}
|
|
191
|
+
onError(wrapped);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Send one frame and await one response, with a hard total budget. Resolves
|
|
199
|
+
// to the parsed response or rejects on timeout / parse error / socket error.
|
|
200
|
+
export function sendOne(
|
|
201
|
+
sock: Socket,
|
|
202
|
+
req: Request,
|
|
203
|
+
totalBudgetMs: number,
|
|
204
|
+
): Promise<Response> {
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
let settled = false;
|
|
207
|
+
const finish = (fn: () => void) => {
|
|
208
|
+
if (settled) return;
|
|
209
|
+
settled = true;
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
sock.removeAllListeners();
|
|
212
|
+
try {
|
|
213
|
+
fn();
|
|
214
|
+
} catch {}
|
|
215
|
+
};
|
|
216
|
+
const timer = setTimeout(() => {
|
|
217
|
+
finish(() => {
|
|
218
|
+
sock.destroy();
|
|
219
|
+
reject(new Error("TIMEOUT"));
|
|
220
|
+
});
|
|
221
|
+
}, totalBudgetMs);
|
|
222
|
+
|
|
223
|
+
const reader = makeFrameReader(
|
|
224
|
+
(frame) => finish(() => resolve(frame as Response)),
|
|
225
|
+
(err) => finish(() => reject(err)),
|
|
226
|
+
);
|
|
227
|
+
sock.on("data", reader);
|
|
228
|
+
sock.on("error", (err) => finish(() => reject(err)));
|
|
229
|
+
sock.on("close", () =>
|
|
230
|
+
finish(() => reject(new Error("socket closed before response"))),
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
sock.write(encodeFrame(req));
|
|
234
|
+
});
|
|
235
|
+
}
|