@promptctl/cc-candybar 1.149.4 → 1.150.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.
@@ -0,0 +1,163 @@
1
+ // seams.js — the fragments of the app's own source the injector anchors on, and what it splices in.
2
+ //
3
+ // This file is the ONLY place in the tree that knows anything about Claude Code's internals, and a
4
+ // seam deliberately knows as little as possible: one fragment of source, and one statement to insert
5
+ // before it. Everything else — which module carries it, at what offset — is resolved from the
6
+ // installed binary at launch by seam-plan.js. There is no chunk name here and no version literal,
7
+ // because both change every release and a map that changes weekly is a map that is already lying.
8
+ //
9
+ // WHY THESE ANCHORS SURVIVE MINIFICATION. Bun renames every module-scope binding — the gate function
10
+ // below was renamed between two releases a week apart — but it cannot rename a PROPERTY, and it
11
+ // cannot rewrite a STRING LITERAL the app sends to a server. Property names and literals come
12
+ // through verbatim. Anchoring on one of those rather than on an offset, a variable, or a line number
13
+ // is what makes this hold across releases without a table someone maintains by hand. No minified
14
+ // name is spelled here: the one place any of them appears is the dated measurement below.
15
+
16
+ 'use strict';
17
+
18
+ // The global a spliced statement calls. The host installs it before a single module is evaluated,
19
+ // which is why inserted code can call it unguarded — an absent registrar is unrepresentable rather
20
+ // than survivable. [LAW:no-defensive-null-guards]
21
+ const REGISTRAR = '__CC_EXTRA_SEAM__';
22
+
23
+ // THE PROPOSE-GOAL SEAM — give the model the goal tool the shipped build already carries.
24
+ //
25
+ // `/goal` has two shipped forms. The one a person types is a slash command; the one the MODEL can
26
+ // reach is a real tool, `ProposeGoal`, which enqueues `/goal <condition>` on the session's own
27
+ // message queue — the shipped way a tool runs a slash command. That tool is already built, already
28
+ // in `getAllBaseTools()`'s array, and already excluded from the offered set by its own `isEnabled()`.
29
+ //
30
+ // MEASURED on the shipped 2.1.280 (a dated observation, not an input — nothing below reads it, and
31
+ // every name in it is minified and already different from 2.1.278's). `isEnabled()` is five
32
+ // conjuncts, and on an ordinary interactive local session exactly one of them is false:
33
+ //
34
+ // isEnabled(){ if(Te()||Zn())return!1; if(At())return!1; if(!Pjt())return!1;
35
+ // let e=pze(); if(e==="disabled")return!1; return E(e),!0 }
36
+ //
37
+ // Te() — the session is NOT interactive. false here: it is.
38
+ // Zn() — the workspace is remote. false here: it is local.
39
+ // At() — `s9()==="bg"`, a background job. false here: it is a terminal session.
40
+ // pze() — the `modelProposedGoals` setting, `"auto"` when absent, and only `"disabled"` refuses.
41
+ // Pjt() — `x("tengu_propose_goal",!1)`, a SERVER-SIDE feature gate, default false. This one.
42
+ //
43
+ // WHY THE GATE AND NOT THE TOOL. That gate is the app's single authority on whether the goal feature
44
+ // exists at all: the tool's `isEnabled` reads it, and so does the `/config` panel, which shows the
45
+ // `modelProposedGoals` row only when the gate is on. Splicing the gate turns the feature on once, at
46
+ // its source, and both consumers agree by construction. Splicing `isEnabled` instead would leave a
47
+ // session whose goal tool works and whose settings panel denies the tool exists — two maps of one
48
+ // fact, drifting the moment either is read. [LAW:one-source-of-truth] [LAW:single-enforcer]
49
+ //
50
+ // WHY NOT CONFIGURATION. Feature values resolve environment override -> config override -> remote
51
+ // payload -> disk cache -> default, and on a shipped build the first two are compiled to
52
+ // `return null` / `return`. When the remote payload is non-empty but lacks the key the resolver
53
+ // returns the default and SKIPS the disk cache entirely, so seeding the cache does nothing — and
54
+ // writing it would make this a second writer to a file the gate client owns and rewrites on its own
55
+ // schedule. The gate is a runtime value; the only honest place to change it is at runtime.
56
+ //
57
+ // WHY THE ANCHOR IS SHAPED THE WAY IT IS. `"tengu_propose_goal"` is a key the app sends to a feature
58
+ // service, so it survives minification untouched and occurs exactly ONCE across all 1,991 js modules.
59
+ // The rest of the anchor is not there to find the site — the literal already did that — it is there
60
+ // to PROVE the site is a statement whose value is the gate's answer, because the only insertion that
61
+ // could go wrong here is one absorbed into a larger expression that inverts it:
62
+ //
63
+ // the `function <name>(){` pins the brace to a FUNCTION BODY, so the spliced `return true;`
64
+ // lookbehind is provably the first statement that function runs, and the leading `[;}\n]` pins that
65
+ // declaration to a STATEMENT POSITION — a function declaration, never an expression
66
+ // whose value an enclosing operator consumes. Neither half is ornamental. A lookbehind
67
+ // of just `\{` admits `if(W()){return P(...)}else{...}`: it resolves to one site,
68
+ // splices cleanly, and leaves a gate that still answers the server on the other branch
69
+ // — resolved to the wrong place and still running, which is the one failure this whole
70
+ // anchor exists to prevent. Without `[;}\n]`, `return!function R(){return P(...)}()`
71
+ // admits the same way, and there an operator inverts what the splice returns. The
72
+ // `function ` keyword carries its own weight: `\w+ ` in its place admits the valid class
73
+ // members `get G(){`, `async G(){` and `static G(){`.
74
+ // IT DOES NOT PROVE NESTING DEPTH — no regex counts braces. A declaration inside another
75
+ // function body still matches whenever `;`, `}` or a newline precedes it, and
76
+ // `seams.test.js` pins that as the known limit rather than implying it away. Proving top
77
+ // level would take a parser, and it would refuse the one nesting that actually occurs: a
78
+ // Bun `__esm`/`__commonJS` chunk wrapper, where the nested declaration IS the gate and
79
+ // splicing into it is correct. The residual is a hand-written
80
+ // `function outer(){…function R(){gate}return!R()}`, which minification does not produce
81
+ // from this source. The lookbehind is variable-length, which V8 supports and most
82
+ // engines do not; node is the only host here, so that is a fact rather than a bet.
83
+ // `\s+` a `return` whose operand is a call has to be separated from it, so requiring the
84
+ // space is what tells `return P(...)` apart from a call to a function NAMED
85
+ // `returnP(...)`. `\s*` would match the second and splice a return into a function
86
+ // that never asked the gate anything. (A negated call, `return!P(...)`, is refused by
87
+ // the identifier class that follows, not by this.)
88
+ // the quote the closing `"` pins the exact key, so a future `"tengu_propose_goal_v2"` refuses
89
+ // instead of resolving onto a gate that means something else.
90
+ //
91
+ // Every one of those is a REFUSAL when the shape moves, and a refusal costs the user the hosted
92
+ // session and never a working one: the host reports `no-module-carries-the-seam` and the caller falls
93
+ // back to stock claude. A seam that resolved to the wrong place is the failure worth engineering
94
+ // against, because that one still runs. [LAW:no-silent-failure]
95
+ //
96
+ // A patch enters this list as a VALUE — an anchor and the statement to insert — and every other part
97
+ // of the mechanism is already unconditional: the plan is resolved, the transform is applied and the
98
+ // registrar is installed on every boot, whether this list holds one seam or ten. Adding a patch is
99
+ // adding data, never a new path. [LAW:dataflow-not-control-flow]
100
+ // THE STATUSLINE-PUSH SEAM — draw the status line from frames cc-candybar's daemon pushes
101
+ // (statusline-push.js says why and how).
102
+ //
103
+ // MEASURED on the shipped 2.1.299 (dated, not an input). The status line controller is one class;
104
+ // it draws through a PRIVATE emitter, and runs the command from an arrow inside its own method:
105
+ //
106
+ // #R(e,n,r,s){ … await _Yo({signal:e, executeCommand:()=>{ let M=this.#t, z=kYo({…});
107
+ // if(!e.aborted)this.#b(wYo(z)); return this.#e.execute(z,e,r) },
108
+ // getCommandLength:()=>…, pendingResultLogRef:this.#g, onResult:(M)=>this.#S(M), … }) }
109
+ // #S(e){ if(this.#n.text===e)return; this.#n={text:e}; …; this.#o.emit() }
110
+ //
111
+ // `z` is the statusline JSON, `this.#e.execute(z,e,r)` runs the command, and `#S` is what its output
112
+ // is drawn through. A private name can only be written inside the class body, which is exactly where
113
+ // this splice lands: `this` is the controller and `this.#S` is reachable. The anchor CAPTURES the
114
+ // pieces the insert needs — the deps field, the payload, the run's other two arguments, the emitter —
115
+ // so no minified name is written here; each is read off the release being spliced.
116
+ //
117
+ // THE SHAPE OF THE INSERT. A splice only adds text before its anchor, and the anchor is the command
118
+ // call itself, so the original call stays after the insert as the right operand of `||`:
119
+ //
120
+ // return <registrar>.statusLine(this, z, (t)=>this.#S(t), ()=>this.#e.execute(z,e,r)) || this.#e.execute(z,e,r)
121
+ //
122
+ // `statusLine` is async, so it always returns a promise and the right operand never runs; the run
123
+ // goes through the thunk the registrar is handed, once.
124
+ //
125
+ // WHY THE ANCHOR IS SHAPED THE WAY IT IS. `.execute(` alone occurs all over the graph; what makes
126
+ // this one the status line's is what follows it — the `getCommandLength:` key of the same object
127
+ // literal, then that literal's `onResult:` arrow into a private method — keys the minifier cannot
128
+ // rename. `(?<=return )` pins the anchor to the operand of the arrow's `return`, so the `||` lands in
129
+ // a return expression and nowhere an operator could absorb it. The `[^]{0,400}?` between
130
+ // `getCommandLength:` and `onResult:` spans the two keys between them in this literal (about 120
131
+ // characters in 2.1.299); bounded, so it cannot reach into another object literal's `onResult:`.
132
+ // Resolved to exactly one site in every installed release, 2.1.267 through 2.1.299.
133
+ const STATUSLINE_PUSH = {
134
+ name: 'statusline-push',
135
+ anchor:
136
+ /(?<=return )this\.(?<deps>#[\w$]+)\.execute\((?<payload>[\w$]+),(?<rest>[\w$]+,[\w$]+)\)(?=\},getCommandLength:[^]{0,400}?onResult:\([\w$]+\)=>this\.(?<emit>#[\w$]+)\()/,
137
+ insert: `globalThis.${REGISTRAR}.statusLine(this,$<payload>,(t)=>this.$<emit>(t),()=>this.$<deps>.execute($<payload>,$<rest>))||`,
138
+ // THE BREAKPOINT FORM, for a stock Bun session reached over its inspector (bun-attach.js), where
139
+ // nothing can be spliced. A breakpoint on the `return` the anchor stands after — the lookbehind is
140
+ // why the offset is minus its length — evaluates `action` in the controller's own frame on every
141
+ // command run, without pausing. That frame is inside the class body, so `this.#S` evaluates there
142
+ // (MEASURED on 2.1.299: JSC accepts the private name in-frame), and the action hands the registrar
143
+ // the controller, its deps object and its emitter. The registrar wraps `deps.execute` once, so the
144
+ // run answers with the newest pushed frame exactly as the splice's `||` does. Wrapping deps is
145
+ // per-controller because the app builds a fresh deps object literal for each controller it
146
+ // constructs (2.1.299). A breakpoint cannot replace a return value, which is why the wrap and not
147
+ // the action is what changes the answer.
148
+ attach: {
149
+ offset: -'return '.length,
150
+ action: `globalThis.${REGISTRAR}.attachStatusLine(this,this.$<deps>,(t)=>this.$<emit>(t))`,
151
+ },
152
+ };
153
+
154
+ const SEAMS = [
155
+ {
156
+ name: 'propose-goal',
157
+ anchor: /(?<=[;}\n]function [A-Za-z_$][\w$]*\(\)\{)return\s+[A-Za-z_$][\w$]*\("tengu_propose_goal"/,
158
+ insert: 'return true;',
159
+ },
160
+ STATUSLINE_PUSH,
161
+ ];
162
+
163
+ module.exports = { REGISTRAR, SEAMS };
@@ -0,0 +1,179 @@
1
+ // statusline-push.js — draw the status line from the frames cc-candybar's daemon pushes, instead of
2
+ // only the output of the command Claude Code reruns.
3
+ //
4
+ // WHY. Claude Code reruns the status line command once a second at best (`refreshInterval` is
5
+ // clamped to a whole second, and every tick passes a 300 ms debounce that restarts itself), so an
6
+ // animated bar moves at one frame a second. cc-candybar's daemon can push a session's frames as it
7
+ // draws them — up to thirty a second while the bar moves, and the moment a click changes it — over a
8
+ // `subscribe` request on its socket (cc-candybar candybar-animation-wbxz.jmm.usl). This module holds
9
+ // one such subscription per status line controller and hands each frame to the controller's own
10
+ // emitter, the one the command's output goes through, so the bar is drawn by the code that always
11
+ // drew it.
12
+ //
13
+ // THE COMMAND KEEPS RUNNING. The daemon draws a session's frames from the latest statusline JSON it
14
+ // was sent, and the command is what sends it: Claude Code builds that JSON and runs the command on
15
+ // its own schedule, as it always has, so the data stays exactly as fresh as a stock session's. What
16
+ // changes is what the command's own answer is used for. While frames arrive, the answer handed back
17
+ // to the controller is the newest pushed frame, not the command's output — that output is the bar
18
+ // as it stood when the command ran, an instant older than the frame already on screen, and showing
19
+ // it would step the animation backwards once a tick. While no frames arrive (no daemon listening,
20
+ // one too old to know `subscribe`, a session it has no data for yet), the command's output is the
21
+ // answer, exactly as in a stock session: both come from the same daemon's one renderer, so this is
22
+ // not a fallback to a different source. [LAW:one-source-of-truth]
23
+ //
24
+ // [LAW:effects-at-boundaries] the socket is passed in (`connect`), so the suite drives this with no
25
+ // daemon. The one thing this module knows of cc-candybar's internals is its wire contract, below.
26
+
27
+ 'use strict';
28
+
29
+ // cc-candybar's wire contract (its src/daemon/protocol.ts): every frame a 4-byte big-endian length
30
+ // and a UTF-8 JSON body; a request names the protocol version it speaks; the daemon listens on the
31
+ // socket `CC_CANDYBAR_SOCKET` names, else the one derived from the user's uid. A daemon on another
32
+ // version answers VERSION_MISMATCH, which this module reports and stops on — never guesses past.
33
+ const PROTOCOL_VERSION = 3;
34
+ const HEADER_BYTES = 4;
35
+ const MAX_FRAME_BYTES = 16 * 1024 * 1024;
36
+
37
+ const daemonSocketPath = (env, uid) => env.CC_CANDYBAR_SOCKET || `/tmp/cc-candybar-${uid}/socket`;
38
+
39
+ const encodeFrame = (value) => {
40
+ const body = Buffer.from(JSON.stringify(value), 'utf8');
41
+ const header = Buffer.alloc(HEADER_BYTES);
42
+ header.writeUInt32BE(body.length, 0);
43
+ return Buffer.concat([header, body]);
44
+ };
45
+
46
+ // Calls `onFrame` with each complete frame's parsed body; `onError` once, on a frame that cannot be
47
+ // one (too large, not JSON), after which it reads nothing more.
48
+ function frameReader(onFrame, onError) {
49
+ let buf = Buffer.alloc(0);
50
+ let broken = false;
51
+ return (chunk) => {
52
+ if (broken) return;
53
+ buf = Buffer.concat([buf, chunk]);
54
+ while (buf.length >= HEADER_BYTES) {
55
+ const len = buf.readUInt32BE(0);
56
+ if (len > MAX_FRAME_BYTES) { broken = true; onError(new Error(`frame too large: ${len}`)); return; }
57
+ if (buf.length < HEADER_BYTES + len) return;
58
+ const body = buf.subarray(HEADER_BYTES, HEADER_BYTES + len).toString('utf8');
59
+ buf = buf.subarray(HEADER_BYTES + len);
60
+ let frame;
61
+ try { frame = JSON.parse(body); } catch (e) { broken = true; onError(e); return; }
62
+ onFrame(frame);
63
+ }
64
+ };
65
+ }
66
+
67
+ // What Claude Code does to a status line command's stdout before it draws it (MEASURED in 2.1.299's
68
+ // command runner: trim, trim each line, drop the empty ones; nothing left is no status line). A
69
+ // pushed frame goes through the same, so it draws exactly as the command's output would.
70
+ const asStatusText = (output) =>
71
+ output.trim().split('\n').flatMap((line) => line.trim() || []).join('\n') || undefined;
72
+
73
+ // The animation lane this subscription is (cc-candybar candybar-animation-wbxz.81o.azo): the daemon
74
+ // streams it only while the session's settings switch it on (`🧪 statusline (B)` in the settings
75
+ // menu, `globals.experimentalStatusline`), and refuses it SWITCHED_OFF otherwise.
76
+ const LANE = 'statusline';
77
+
78
+ // How long a subscription the daemon refused SWITCHED_OFF waits before it asks again: a toggle turned
79
+ // on is picked up within this, and a lane left off costs the daemon one refusal per wait, not one per
80
+ // run of the command.
81
+ const SWITCHED_OFF_RETRY_MS = 5000;
82
+
83
+ // [LAW:types-are-the-program] One controller's subscription, as state: none open; one open and not
84
+ // yet carrying frames; one carrying frames, with the newest; switched off in the session's settings
85
+ // as of `at` (ms), asked again once SWITCHED_OFF_RETRY_MS has passed; or refused for good by the
86
+ // daemon (a version it does not speak, a request it does not know), which is not retried for that
87
+ // session.
88
+ // { kind: 'closed' } | { kind: 'open', socket } | { kind: 'live', socket, text } |
89
+ // { kind: 'off', at } | { kind: 'refused', reason }
90
+
91
+ function createStatusLinePush({ connect, socketPath, report, now = Date.now }) {
92
+ // WEAK, so a controller the app drops takes its subscription's bookkeeping with it. The socket
93
+ // itself is closed by the stream ending or by the session moving; a controller that stops running
94
+ // its command and is collected leaves its socket to close when the daemon or the process does.
95
+ const feeds = new WeakMap();
96
+
97
+ const open = (feed, sessionId) => {
98
+ const socket = connect(socketPath);
99
+ feed.sessionId = sessionId;
100
+ feed.state = { kind: 'open', socket };
101
+ const mine = () => feed.state.socket === socket;
102
+ const end = (why) => {
103
+ if (!mine()) return;
104
+ feed.state = { kind: 'closed' };
105
+ socket.destroy();
106
+ report(`statusline-push sid=${JSON.stringify(sessionId)} ended: ${why}`);
107
+ };
108
+ socket.on('connect', () => {
109
+ socket.write(encodeFrame({ v: PROTOCOL_VERSION, kind: 'subscribe', sessionId, lane: LANE }));
110
+ if (!feed.off) report(`statusline-push sid=${JSON.stringify(sessionId)} subscribed`);
111
+ });
112
+ socket.on('data', frameReader(
113
+ (resp) => {
114
+ if (!mine()) return;
115
+ if (resp && resp.ok === true && typeof resp.output === 'string') {
116
+ const text = asStatusText(resp.output);
117
+ feed.state = { kind: 'live', socket, text };
118
+ feed.off = false;
119
+ feed.emit(text);
120
+ return;
121
+ }
122
+ const code = resp && resp.code;
123
+ // A daemon that does not know the request, or speaks another version, will refuse it the
124
+ // same way next tick: stop asking for this session, and say why once.
125
+ if (code === 'BAD_REQUEST' || code === 'VERSION_MISMATCH') {
126
+ feed.state = { kind: 'refused', reason: `${code}: ${resp.error}` };
127
+ socket.destroy();
128
+ report(`statusline-push sid=${JSON.stringify(sessionId)} refused: ${code}: ${resp.error}`);
129
+ return;
130
+ }
131
+ // The lane is switched off in the session's settings: the command's own answer shows, and
132
+ // the subscription is asked again after a wait. Said once, until a frame streams again.
133
+ if (code === 'SWITCHED_OFF') {
134
+ feed.state = { kind: 'off', at: now() };
135
+ socket.destroy();
136
+ if (!feed.off) report(`statusline-push sid=${JSON.stringify(sessionId)} switched off: ${resp.error}`);
137
+ feed.off = true;
138
+ return;
139
+ }
140
+ // A frame the daemon could not draw: the command's own answer shows how it failed, as in a
141
+ // stock session, until a frame draws again.
142
+ feed.state = { kind: 'open', socket };
143
+ report(`statusline-push sid=${JSON.stringify(sessionId)} frame failed: ${code}: ${resp && resp.error}`);
144
+ },
145
+ (e) => end(`unreadable frame: ${e.message}`),
146
+ ));
147
+ socket.on('error', (e) => end(e.message));
148
+ socket.on('close', () => end('closed by the daemon'));
149
+ };
150
+
151
+ // What the spliced statement calls each time the controller runs its command: the controller, the
152
+ // statusline JSON it built, its own emitter, and the run itself. Always a promise, so the splice's
153
+ // `||` never reaches the original call it leaves in place.
154
+ async function statusLine(controller, payload, emit, run) {
155
+ // `off`: the last refusal said the lane is switched off, so the retries it causes are not news.
156
+ const feed = feeds.get(controller) || { sessionId: null, state: { kind: 'closed' }, emit, off: false };
157
+ feeds.set(controller, feed);
158
+ feed.emit = emit;
159
+ // [LAW:dataflow-not-control-flow] the session the subscription is for is read off every payload,
160
+ // so a new session id (after /clear) moves it, and a closed one reopens on the next run.
161
+ const sessionId = payload.session_id;
162
+ const moved = feed.sessionId !== sessionId;
163
+ if (moved && feed.state.socket) {
164
+ const old = feed.state.socket;
165
+ feed.state = { kind: 'closed' };
166
+ old.destroy();
167
+ }
168
+ if (moved) feed.off = false;
169
+ const due = feed.state.kind === 'closed' ||
170
+ (feed.state.kind === 'off' && now() - feed.state.at >= SWITCHED_OFF_RETRY_MS);
171
+ if (moved || due) open(feed, sessionId);
172
+ const ran = await run();
173
+ return feed.state.kind === 'live' ? feed.state.text : ran;
174
+ }
175
+
176
+ return { statusLine };
177
+ }
178
+
179
+ module.exports = { LANE, PROTOCOL_VERSION, SWITCHED_OFF_RETRY_MS, asStatusText, createStatusLinePush, daemonSocketPath, encodeFrame, frameReader };
@@ -0,0 +1,125 @@
1
+ // ws-unix.js — a WebSocket client over a unix socket, for the one peer this repo talks to that way:
2
+ // the inspector a Bun binary opens when `BUN_INSPECT=ws+unix://<path>` is set.
3
+ //
4
+ // WHY NOT node's WebSocket. Node's global WebSocket dials TCP only, and the inspector is deliberately
5
+ // NOT on TCP: anything that can reach it can run code in the user's session, and a unix socket in a
6
+ // 0700 directory is reachable by this user alone. So the client is the handshake (node's http client
7
+ // already speaks Upgrade over a socketPath) and RFC 6455 framing, which is all this file is.
8
+ //
9
+ // The framing is pure and exported, so the suite checks it against the RFC's own vectors without a
10
+ // server. [LAW:effects-at-boundaries]
11
+
12
+ 'use strict';
13
+
14
+ const crypto = require('crypto');
15
+ const http = require('http');
16
+
17
+ const OPCODES = { continuation: 0x0, text: 0x1, binary: 0x2, close: 0x8, ping: 0x9, pong: 0xa };
18
+
19
+ // One client-to-server frame: FIN set, MASKED (RFC 6455 5.3 requires a client to mask every frame).
20
+ // `mask` is a parameter so the suite can pin it to the RFC's example key.
21
+ function encodeFrame(opcode, payload, mask = crypto.randomBytes(4)) {
22
+ const len = payload.length;
23
+ const lengthBytes = len < 126 ? 0 : len < 0x10000 ? 2 : 8;
24
+ const head = Buffer.alloc(2 + lengthBytes + 4);
25
+ head[0] = 0x80 | opcode;
26
+ head[1] = 0x80 | (lengthBytes === 0 ? len : lengthBytes === 2 ? 126 : 127);
27
+ if (lengthBytes === 2) head.writeUInt16BE(len, 2);
28
+ if (lengthBytes === 8) head.writeBigUInt64BE(BigInt(len), 2);
29
+ mask.copy(head, 2 + lengthBytes);
30
+ const body = Buffer.alloc(len);
31
+ for (let i = 0; i < len; i++) body[i] = payload[i] ^ mask[i % 4];
32
+ return Buffer.concat([head, body]);
33
+ }
34
+
35
+ // Reads server-to-client frames from a byte stream: returns a function to feed chunks, and calls
36
+ // `onFrame({ fin, opcode, payload })` for each complete frame. A server must not mask (RFC 6455 5.1);
37
+ // a masked one is a protocol error, reported through `onError` once, after which nothing more is read.
38
+ function frameReader(onFrame, onError) {
39
+ let buf = Buffer.alloc(0);
40
+ let broken = false;
41
+ return (chunk) => {
42
+ if (broken) return;
43
+ buf = Buffer.concat([buf, chunk]);
44
+ while (buf.length >= 2) {
45
+ const fin = (buf[0] & 0x80) !== 0;
46
+ const opcode = buf[0] & 0x0f;
47
+ if (buf[1] & 0x80) { broken = true; onError(new Error('server sent a masked frame')); return; }
48
+ const short = buf[1] & 0x7f;
49
+ const lengthBytes = short === 126 ? 2 : short === 127 ? 8 : 0;
50
+ if (buf.length < 2 + lengthBytes) return;
51
+ const len = lengthBytes === 2 ? buf.readUInt16BE(2) : lengthBytes === 8 ? Number(buf.readBigUInt64BE(2)) : short;
52
+ const start = 2 + lengthBytes;
53
+ if (buf.length < start + len) return;
54
+ onFrame({ fin, opcode, payload: buf.subarray(start, start + len) });
55
+ buf = buf.subarray(start + len);
56
+ }
57
+ };
58
+ }
59
+
60
+ // Joins fragmented text messages and answers the control frames, handing each whole message to
61
+ // `onMessage(text)`. `write` sends raw bytes; `onClose` is told once, with why.
62
+ function messageReader({ onMessage, write, onClose }) {
63
+ let parts = [];
64
+ return (frame) => {
65
+ if (frame.opcode === OPCODES.ping) { write(encodeFrame(OPCODES.pong, frame.payload)); return; }
66
+ if (frame.opcode === OPCODES.pong) return;
67
+ if (frame.opcode === OPCODES.close) { onClose('closed by the peer'); return; }
68
+ parts.push(frame.payload);
69
+ if (!frame.fin) return;
70
+ const text = Buffer.concat(parts).toString('utf8');
71
+ parts = [];
72
+ onMessage(text);
73
+ };
74
+ }
75
+
76
+ // Open a WebSocket to `path` on the server listening at `socketPath`. Resolves once the handshake is
77
+ // accepted with { send(text), close(why?), listen({ onMessage, onClose }) }, or rejects naming why it was
78
+ // not.
79
+ function connect(socketPath, path = '/') {
80
+ return new Promise((resolve, reject) => {
81
+ const key = crypto.randomBytes(16).toString('base64');
82
+ const req = http.request({
83
+ socketPath, path,
84
+ headers: { Connection: 'Upgrade', Upgrade: 'websocket', 'Sec-WebSocket-Key': key, 'Sec-WebSocket-Version': '13' },
85
+ });
86
+ req.on('error', reject);
87
+ req.on('response', (res) => reject(new Error(`inspector refused the upgrade: HTTP ${res.statusCode}`)));
88
+ req.on('upgrade', (res, socket, head) => {
89
+ // The accept header proves the peer read OUR key, not a stale or proxied answer (RFC 6455 4.1).
90
+ const expected = crypto.createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
91
+ if (res.headers['sec-websocket-accept'] !== expected) { socket.destroy(); reject(new Error('inspector answered the upgrade with the wrong accept key')); return; }
92
+ let listener = null;
93
+ let endedBecause = null;
94
+ const close = (why) => {
95
+ if (endedBecause !== null) return;
96
+ endedBecause = why;
97
+ socket.destroy();
98
+ if (listener) listener.onClose(why);
99
+ };
100
+ const feed = frameReader(
101
+ // An ended connection delivers nothing, a frame read after its close frame in the same chunk included.
102
+ messageReader({ onMessage: (text) => endedBecause === null && listener.onMessage(text), write: (bytes) => socket.write(bytes), onClose: close }),
103
+ (e) => close(e.message),
104
+ );
105
+ socket.on('error', (e) => close(e.message));
106
+ socket.on('close', () => close('socket closed'));
107
+ resolve({
108
+ send: (text) => socket.write(encodeFrame(OPCODES.text, Buffer.from(text, 'utf8'))),
109
+ close: (why = 'closed by this client') => close(why),
110
+ // Both handlers at once, and only then is anything read — the bytes that came with the upgrade
111
+ // first — so no message or close lands before there is someone to tell it to.
112
+ // [LAW:no-ambient-temporal-coupling] delivery starts at the one moment a listener exists.
113
+ listen: (handlers) => {
114
+ listener = handlers;
115
+ if (endedBecause !== null) { handlers.onClose(endedBecause); return; }
116
+ if (head.length) feed(head);
117
+ socket.on('data', feed);
118
+ },
119
+ });
120
+ });
121
+ req.end();
122
+ });
123
+ }
124
+
125
+ module.exports = { OPCODES, encodeFrame, frameReader, messageReader, connect };