@fnclaude/cli 1.1.0 → 2.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/bin/fnc.js +34 -79
- package/package.json +6 -9
- package/share/fnclaude/templates/handoff.template.md +11 -0
- package/src/argv/classify.ts +48 -0
- package/src/argv/expand.ts +51 -0
- package/src/argv/intake.ts +52 -0
- package/src/argv/magic.ts +103 -0
- package/src/argv/parse.ts +213 -0
- package/src/argv/preserve-args.ts +333 -0
- package/src/argv/sentinel.ts +41 -0
- package/src/argv/short-flags.ts +152 -0
- package/src/config/load.ts +116 -0
- package/src/handoff/awaiter.ts +140 -0
- package/src/handoff/clean-env.ts +45 -0
- package/src/handoff/kill-and-exec.ts +110 -0
- package/src/handoff/spawn-launcher.ts +185 -0
- package/src/handoff/summary-file.ts +86 -0
- package/src/handoff/trigger.ts +90 -0
- package/src/help-version.ts +151 -0
- package/src/launch/compose-env.ts +34 -0
- package/src/launch/cross-cwd-parse.ts +69 -0
- package/src/launch/cross-cwd-relaunch.ts +95 -0
- package/src/launch/find-claude.ts +52 -0
- package/src/launch/live-permission-reader.ts +133 -0
- package/src/launch/ring-buffer.ts +92 -0
- package/src/main.ts +580 -437
- package/src/mcp/dispatch.ts +240 -0
- package/src/mcp/handlers/clipboard-backends.ts +176 -0
- package/src/mcp/handlers/clipboard.ts +62 -0
- package/src/mcp/handlers/restart.ts +156 -0
- package/src/mcp/handlers/spawn.ts +219 -0
- package/src/mcp/handlers/switch.ts +272 -0
- package/src/mcp/inject-config.ts +59 -0
- package/src/mcp/jsonrpc-server.ts +154 -0
- package/src/mcp/listener.ts +141 -0
- package/src/mcp/parent-dispatch.ts +154 -0
- package/src/mcp/socket-path.ts +48 -0
- package/src/mcp/wire.ts +181 -0
- package/src/name/auto-name.ts +162 -0
- package/src/name/llm-prompt.ts +14 -0
- package/src/name/sanitize.ts +57 -0
- package/src/name/sdk-llm.ts +42 -0
- package/src/noop/seed.ts +63 -0
- package/src/noop/template-source.ts +62 -0
- package/src/path/ensure-cwd.ts +95 -0
- package/src/path/resolve.ts +58 -0
- package/src/prompts/dir.ts +61 -0
- package/src/prompts/load.ts +100 -0
- package/src/prompts/select.ts +43 -0
- package/src/repo/clone-exec.ts +37 -0
- package/src/repo/clone.ts +45 -0
- package/src/repo/gh-runner.ts +68 -0
- package/src/repo/host-aliases.ts +58 -0
- package/src/repo/owner-lookup.ts +71 -0
- package/src/repo/ref.ts +146 -0
- package/src/repo/repo-settings.ts +99 -0
- package/src/repo/resolve-input.ts +179 -0
- package/src/repo/template.ts +92 -0
- package/src/warnings/buffer.ts +39 -0
- package/src/worktree/auto-tmux.ts +45 -0
- package/src/worktree/git-list.ts +73 -0
- package/src/worktree/intercept.ts +150 -0
- package/bin/preflight.js +0 -66
- package/prompts/agent-pitfall.md +0 -1
- package/prompts/noop-router.md +0 -186
- package/prompts/project-switch.md +0 -64
- package/prompts/restart.md +0 -50
- package/prompts/spawn.md +0 -62
- package/src/argParser.ts +0 -367
- package/src/args/preserve.ts +0 -338
- package/src/args.ts +0 -239
- package/src/argv.ts +0 -203
- package/src/autoname.ts +0 -273
- package/src/clipboard.ts +0 -149
- package/src/config.ts +0 -369
- package/src/errors.ts +0 -13
- package/src/handoff.ts +0 -108
- package/src/help.ts +0 -139
- package/src/hostAliases.ts +0 -139
- package/src/index.ts +0 -120
- package/src/mcp/client.ts +0 -645
- package/src/mcp/protocol.ts +0 -445
- package/src/mcp/socketListener.ts +0 -540
- package/src/noop.ts +0 -106
- package/src/passthrough.ts +0 -36
- package/src/paths.ts +0 -55
- package/src/prompts.ts +0 -279
- package/src/pty/unix.ts +0 -429
- package/src/pty/windows.ts +0 -125
- package/src/pty.ts +0 -380
- package/src/repoRef.ts +0 -158
- package/src/repoSettings.ts +0 -144
- package/src/resolver.ts +0 -519
- package/src/sanitize.ts +0 -120
- package/src/sessionState.ts +0 -220
- package/src/silentRelaunch.ts +0 -178
- package/src/spawn.ts +0 -163
- package/src/template.ts +0 -44
- package/src/warnings.ts +0 -34
- package/src/worktree.ts +0 -201
package/src/mcp/protocol.ts
DELETED
|
@@ -1,445 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MCP internal wire protocol — newline-delimited JSON exchanged on the
|
|
3
|
-
* parent-listener AF_UNIX socket. The `fnclaude mcp` subprocess is the
|
|
4
|
-
* client; the parent fnclaude process is the server. Each connection
|
|
5
|
-
* carries exactly one Request and receives exactly one Response, then
|
|
6
|
-
* closes. No persistent state on the wire; each call stands on its own.
|
|
7
|
-
*
|
|
8
|
-
* Ported from src/mcp_protocol.go in the Go reference (fnclaude@fnrhombus).
|
|
9
|
-
* Wire-format compatibility is the contract — the field names, op string
|
|
10
|
-
* values, and Action string values MUST match Go byte-for-byte: in
|
|
11
|
-
* production the parent listener and the `fnclaude mcp` subprocess will
|
|
12
|
-
* still be a heterogeneous Go/TS pair during cutover.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
// ── Op ─────────────────────────────────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
/** Identifies the requested operation on the parent. */
|
|
18
|
-
export type Op = 'restart' | 'switch' | 'spawn' | 'copy_to_clipboard';
|
|
19
|
-
|
|
20
|
-
export const OpRestart: Op = 'restart';
|
|
21
|
-
export const OpSwitch: Op = 'switch';
|
|
22
|
-
export const OpSpawn: Op = 'spawn';
|
|
23
|
-
export const OpCopy: Op = 'copy_to_clipboard';
|
|
24
|
-
|
|
25
|
-
// ── Action ─────────────────────────────────────────────────────────────────
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* High-level instruction the parent returns to the MCP subprocess, which
|
|
29
|
-
* relays it back to claude as the tool result. Each Action maps to a
|
|
30
|
-
* distinct UX claude will perform.
|
|
31
|
-
*/
|
|
32
|
-
export type Action =
|
|
33
|
-
| 'done'
|
|
34
|
-
| 'needs_confirmation' // deprecated, no longer emitted
|
|
35
|
-
| 'auto_countdown' // deprecated, no longer emitted
|
|
36
|
-
| 'paste_flow'
|
|
37
|
-
| 'error';
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* The requested operation has been performed. For OpSwitch this means the
|
|
41
|
-
* parent will kill claude and re-exec; the MCP subprocess and claude are
|
|
42
|
-
* both about to be terminated. For OpRestart and OpCopy the parent has
|
|
43
|
-
* completed the work.
|
|
44
|
-
*/
|
|
45
|
-
export const ActionDone: Action = 'done';
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Deprecated. Historical — ask mode used to return this to force a
|
|
49
|
-
* needs_confirmation prompt before performing the switch. The constant is
|
|
50
|
-
* retained so older test fixtures that pattern-match on the literal still
|
|
51
|
-
* compile.
|
|
52
|
-
*/
|
|
53
|
-
export const ActionNeedsConfirmation: Action = 'needs_confirmation';
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Deprecated. Historical — numeric mode used to return this so claude
|
|
57
|
-
* would print a countdown announcement, sleep, then call the tool again
|
|
58
|
-
* with confirmed=true. The cancellation-window UX now lives in the
|
|
59
|
-
* prompt.
|
|
60
|
-
*/
|
|
61
|
-
export const ActionAutoCountdown: Action = 'auto_countdown';
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* The parent has prepared the relaunch Command. If ClipboardOK is true
|
|
65
|
-
* the command is already on the user's clipboard; otherwise claude should
|
|
66
|
-
* tell the user to copy Command manually.
|
|
67
|
-
*/
|
|
68
|
-
export const ActionPasteFlow: Action = 'paste_flow';
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* The parent could not perform the operation. Error is the human-readable
|
|
72
|
-
* reason; claude should surface it to the user.
|
|
73
|
-
*/
|
|
74
|
-
export const ActionError: Action = 'error';
|
|
75
|
-
|
|
76
|
-
// ── Request ────────────────────────────────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Shared override fields applicable to OpRestart / OpSwitch / OpSpawn.
|
|
80
|
-
*
|
|
81
|
-
* Empty/undef string values mean "preserve what was on the original
|
|
82
|
-
* command line" (for restart/transfer) or "don't pass this flag" (for
|
|
83
|
-
* spawn). For boolean fields: undefined = preserve existing; true =
|
|
84
|
-
* ensure present; false = ensure absent.
|
|
85
|
-
*/
|
|
86
|
-
export interface RequestOverrides {
|
|
87
|
-
model?: string;
|
|
88
|
-
effort?: string;
|
|
89
|
-
permission_mode?: string;
|
|
90
|
-
allowed_tools?: string;
|
|
91
|
-
agent?: string;
|
|
92
|
-
brief?: boolean | null;
|
|
93
|
-
chrome?: boolean | null;
|
|
94
|
-
ide?: boolean | null;
|
|
95
|
-
verbose?: boolean | null;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Discriminated union of Request variants — exactly one shape per Op.
|
|
100
|
-
* Adding a new Op requires extending this union *and* every `switch`
|
|
101
|
-
* over `req.op` (the dispatcher uses an exhaustive-never check so the
|
|
102
|
-
* compiler enforces this).
|
|
103
|
-
*
|
|
104
|
-
* The wire shape uses snake_case keys (matching Go's `json:` tags). All
|
|
105
|
-
* non-required fields are optional on the wire; the listener tolerates
|
|
106
|
-
* missing keys.
|
|
107
|
-
*/
|
|
108
|
-
export type Request =
|
|
109
|
-
| RestartRequest
|
|
110
|
-
| SwitchRequest
|
|
111
|
-
| SpawnRequest
|
|
112
|
-
| CopyRequest;
|
|
113
|
-
|
|
114
|
-
/** OpRestart: restart the current session in place. */
|
|
115
|
-
export interface RestartRequest extends RequestOverrides {
|
|
116
|
-
op: 'restart';
|
|
117
|
-
/** Required UUID — the current Claude session. */
|
|
118
|
-
session_id?: string;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** OpSwitch: kill claude and relaunch at destination. */
|
|
122
|
-
export interface SwitchRequest extends RequestOverrides {
|
|
123
|
-
op: 'switch';
|
|
124
|
-
/** Destination project ref. */
|
|
125
|
-
destination?: string;
|
|
126
|
-
/** 3-6 word kebab-case session topic. */
|
|
127
|
-
name?: string;
|
|
128
|
-
/** Continuity summary content. */
|
|
129
|
-
summary?: string;
|
|
130
|
-
/**
|
|
131
|
-
* Optional UUID for live-permission-mode auto-capture. Used to read the
|
|
132
|
-
* session JSONL when no explicit permission_mode override was set.
|
|
133
|
-
*/
|
|
134
|
-
session_id?: string;
|
|
135
|
-
/**
|
|
136
|
-
* Deprecated; no longer read by the server. Left on the type so older
|
|
137
|
-
* clients that still serialize confirmed=true don't break.
|
|
138
|
-
*/
|
|
139
|
-
confirmed?: boolean;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** OpSpawn: launch a sibling fnclaude in a new window. */
|
|
143
|
-
export interface SpawnRequest extends RequestOverrides {
|
|
144
|
-
op: 'spawn';
|
|
145
|
-
/** Destination project ref. */
|
|
146
|
-
destination?: string;
|
|
147
|
-
/** 3-6 word kebab-case session topic. */
|
|
148
|
-
name?: string;
|
|
149
|
-
/** Continuity summary content. */
|
|
150
|
-
summary?: string;
|
|
151
|
-
/**
|
|
152
|
-
* Deprecated; no longer read by the server. Left on the type so older
|
|
153
|
-
* clients that still serialize confirmed=true don't break.
|
|
154
|
-
*/
|
|
155
|
-
confirmed?: boolean;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** OpCopy: write text to the clipboard. Carries no override fields. */
|
|
159
|
-
export interface CopyRequest {
|
|
160
|
-
op: 'copy_to_clipboard';
|
|
161
|
-
/** Text to write to the clipboard. */
|
|
162
|
-
text?: string;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// ── Response ───────────────────────────────────────────────────────────────
|
|
166
|
-
|
|
167
|
-
/** Response returned by the parent listener. */
|
|
168
|
-
export interface Response {
|
|
169
|
-
action: Action;
|
|
170
|
-
/** Natural-language guidance for claude / the user. */
|
|
171
|
-
message?: string;
|
|
172
|
-
/** ActionPasteFlow: literal command string the user should paste-and-run. */
|
|
173
|
-
command?: string;
|
|
174
|
-
/** ActionPasteFlow: whether the parent succeeded in copying Command to clipboard. */
|
|
175
|
-
clipboard_ok?: boolean;
|
|
176
|
-
/** ActionAutoCountdown only (deprecated). */
|
|
177
|
-
countdown_seconds?: number;
|
|
178
|
-
/** ActionError: human-readable reason. */
|
|
179
|
-
error?: string;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// ── Codec ──────────────────────────────────────────────────────────────────
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Encode req as a single newline-terminated JSON line. Returned as a
|
|
186
|
-
* Buffer; callers that want a string can `.toString()`.
|
|
187
|
-
*/
|
|
188
|
-
export function encodeRequest(req: Request): Buffer {
|
|
189
|
-
return Buffer.from(`${JSON.stringify(req)}\n`, 'utf8');
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** Encode resp as a single newline-terminated JSON line. */
|
|
193
|
-
export function encodeResponse(resp: Response): Buffer {
|
|
194
|
-
return Buffer.from(`${JSON.stringify(resp)}\n`, 'utf8');
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Decode one newline-terminated JSON line into a Request.
|
|
199
|
-
*
|
|
200
|
-
* The line may or may not include the terminating '\n'; both are accepted
|
|
201
|
-
* (matches Go's bufio.ReadBytes which keeps the delimiter).
|
|
202
|
-
*
|
|
203
|
-
* Throws on malformed JSON or on payloads that fail boundary validation
|
|
204
|
-
* (see `validateRequest` for the rules). Wire input is untrusted —
|
|
205
|
-
* anything that knows the socket path can submit JSON, and the dispatcher
|
|
206
|
-
* acts on it (re-exec, file writes, clipboard). Validate before handing
|
|
207
|
-
* to the dispatcher rather than scattering checks across handlers.
|
|
208
|
-
*/
|
|
209
|
-
export function decodeRequest(line: string | Buffer): Request {
|
|
210
|
-
const text = typeof line === 'string' ? line : line.toString('utf8');
|
|
211
|
-
const trimmed = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
212
|
-
const raw: unknown = JSON.parse(trimmed);
|
|
213
|
-
return validateRequest(raw);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// ── Request validation ─────────────────────────────────────────────────────
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Maximum byte length for each string field on a Request. Values are sized
|
|
220
|
-
* to the field's job:
|
|
221
|
-
* - overrides (model, effort, permission_mode, agent): short identifiers
|
|
222
|
-
* - allowed_tools: comma-joined tool names, room for ~30 longest
|
|
223
|
-
* - session_id: UUID-shaped, but we don't pre-validate the shape here
|
|
224
|
-
* (that lives in the handler so the error message can mention UUID)
|
|
225
|
-
* - destination / name: filesystem paths / kebab-case names
|
|
226
|
-
* - text (clipboard): bounded for typical clipboard payloads
|
|
227
|
-
* - summary: continuity blob — generous but bounded; a malicious peer
|
|
228
|
-
* submitting hundreds of MB would otherwise wedge the writeFile path
|
|
229
|
-
*/
|
|
230
|
-
const STRING_LIMITS: Record<string, number> = {
|
|
231
|
-
// Shared overrides
|
|
232
|
-
model: 64,
|
|
233
|
-
effort: 64,
|
|
234
|
-
permission_mode: 64,
|
|
235
|
-
allowed_tools: 4096,
|
|
236
|
-
agent: 256,
|
|
237
|
-
// Restart / switch / spawn
|
|
238
|
-
session_id: 128,
|
|
239
|
-
destination: 4096,
|
|
240
|
-
name: 256,
|
|
241
|
-
summary: 1024 * 1024, // 1 MiB
|
|
242
|
-
// Copy
|
|
243
|
-
text: 1024 * 1024, // 1 MiB
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
/** Discriminated union of fields legal for each Op. */
|
|
247
|
-
const STRING_FIELDS_BY_OP: Record<Op, readonly string[]> = {
|
|
248
|
-
restart: ['session_id', 'model', 'effort', 'permission_mode', 'allowed_tools', 'agent'],
|
|
249
|
-
switch: [
|
|
250
|
-
'destination',
|
|
251
|
-
'name',
|
|
252
|
-
'summary',
|
|
253
|
-
'session_id',
|
|
254
|
-
'model',
|
|
255
|
-
'effort',
|
|
256
|
-
'permission_mode',
|
|
257
|
-
'allowed_tools',
|
|
258
|
-
'agent',
|
|
259
|
-
],
|
|
260
|
-
spawn: [
|
|
261
|
-
'destination',
|
|
262
|
-
'name',
|
|
263
|
-
'summary',
|
|
264
|
-
'model',
|
|
265
|
-
'effort',
|
|
266
|
-
'permission_mode',
|
|
267
|
-
'allowed_tools',
|
|
268
|
-
'agent',
|
|
269
|
-
],
|
|
270
|
-
copy_to_clipboard: ['text'],
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
const BOOL_FIELDS: readonly string[] = ['brief', 'chrome', 'ide', 'verbose', 'confirmed'];
|
|
274
|
-
|
|
275
|
-
/** Field names that are filesystem paths and must reject ".." traversal. */
|
|
276
|
-
const PATH_FIELDS: readonly string[] = ['destination'];
|
|
277
|
-
|
|
278
|
-
const KNOWN_OPS: readonly Op[] = ['restart', 'switch', 'spawn', 'copy_to_clipboard'];
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Validate a parsed JSON value as a Request. Returns the validated value
|
|
282
|
-
* (typed as Request) on success; throws Error with a human-readable
|
|
283
|
-
* reason on failure. The caller (`decodeRequest`) is the only intended
|
|
284
|
-
* entry point — handlers consume the typed Request and trust its shape.
|
|
285
|
-
*
|
|
286
|
-
* Checks performed:
|
|
287
|
-
* 1. Value is a plain object (not array, null, or scalar).
|
|
288
|
-
* 2. `op` is one of the four known string discriminants.
|
|
289
|
-
* 3. Every string field on the per-op allowlist is either absent or a
|
|
290
|
-
* string under its byte-length cap, with no null bytes.
|
|
291
|
-
* 4. Path-shaped fields (`destination`) additionally reject `..`
|
|
292
|
-
* segments (delimited by `/` or string ends).
|
|
293
|
-
* 5. Boolean override fields are either absent, true, false, or null
|
|
294
|
-
* (null is treated as "preserve existing" by handlers).
|
|
295
|
-
*
|
|
296
|
-
* Unknown extra keys are tolerated (forward compatibility with newer
|
|
297
|
-
* clients) but their values are not validated.
|
|
298
|
-
*/
|
|
299
|
-
export function validateRequest(value: unknown): Request {
|
|
300
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
301
|
-
throw new Error('request must be a JSON object');
|
|
302
|
-
}
|
|
303
|
-
const obj = value as Record<string, unknown>;
|
|
304
|
-
const op = obj.op;
|
|
305
|
-
if (typeof op !== 'string' || !KNOWN_OPS.includes(op as Op)) {
|
|
306
|
-
throw new Error(`unknown op ${JSON.stringify(op)}`);
|
|
307
|
-
}
|
|
308
|
-
const allowed = STRING_FIELDS_BY_OP[op as Op];
|
|
309
|
-
for (const field of allowed) {
|
|
310
|
-
const v = obj[field];
|
|
311
|
-
if (v === undefined) continue;
|
|
312
|
-
if (typeof v !== 'string') {
|
|
313
|
-
throw new Error(`field ${field} must be a string`);
|
|
314
|
-
}
|
|
315
|
-
if (v.includes('\x00')) {
|
|
316
|
-
throw new Error(`field ${field} contains a null byte`);
|
|
317
|
-
}
|
|
318
|
-
const cap = STRING_LIMITS[field] ?? 1024;
|
|
319
|
-
if (Buffer.byteLength(v, 'utf8') > cap) {
|
|
320
|
-
throw new Error(`field ${field} exceeds max length ${cap}`);
|
|
321
|
-
}
|
|
322
|
-
if (PATH_FIELDS.includes(field) && hasParentSegment(v)) {
|
|
323
|
-
throw new Error(`field ${field} contains a path-traversal segment ("..")`);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
for (const field of BOOL_FIELDS) {
|
|
327
|
-
const v = obj[field];
|
|
328
|
-
if (v === undefined || v === null) continue;
|
|
329
|
-
if (typeof v !== 'boolean') {
|
|
330
|
-
throw new Error(`field ${field} must be a boolean`);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
return obj as unknown as Request;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* Returns true when the path contains a literal `..` segment — i.e. `..`
|
|
338
|
-
* delimited by `/` (or by start/end of the string). Catches `../etc`,
|
|
339
|
-
* `/foo/../bar`, `foo/..`, and bare `..`; ignores `foo..bar` and any
|
|
340
|
-
* other substring where `..` is part of a larger name.
|
|
341
|
-
*/
|
|
342
|
-
function hasParentSegment(p: string): boolean {
|
|
343
|
-
// Split on '/' is sufficient — POSIX path semantics, and a Windows
|
|
344
|
-
// backslash path would never be a legitimate destination on the wire.
|
|
345
|
-
return p.split('/').includes('..');
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/** Decode one newline-terminated JSON line into a Response. */
|
|
349
|
-
export function decodeResponse(line: string | Buffer): Response {
|
|
350
|
-
const text = typeof line === 'string' ? line : line.toString('utf8');
|
|
351
|
-
const trimmed = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
352
|
-
return JSON.parse(trimmed) as Response;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* A minimal Readable surface: `on('data' | 'end' | 'error' | 'close', ...)`.
|
|
357
|
-
* Both Node's `net.Socket` and the Readable streams from `node:stream`
|
|
358
|
-
* satisfy this. We deliberately avoid `for await ... of` on the socket —
|
|
359
|
-
* breaking out of that loop calls the iterator's return() which
|
|
360
|
-
* **destroys the underlying socket**, preventing the response write.
|
|
361
|
-
*/
|
|
362
|
-
export interface DataStream {
|
|
363
|
-
on(event: 'data', listener: (chunk: Buffer) => void): this;
|
|
364
|
-
on(event: 'end' | 'close', listener: () => void): this;
|
|
365
|
-
on(event: 'error', listener: (err: Error) => void): this;
|
|
366
|
-
off(event: 'data', listener: (chunk: Buffer) => void): this;
|
|
367
|
-
off(event: 'end' | 'close', listener: () => void): this;
|
|
368
|
-
off(event: 'error', listener: (err: Error) => void): this;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Read one newline-terminated JSON line from a data-emitting stream (a
|
|
373
|
-
* `net.Socket` is the common case). Returns the decoded Request or
|
|
374
|
-
* undefined if the stream ended cleanly before any line was seen
|
|
375
|
-
* (analogous to Go's `io.EOF` return).
|
|
376
|
-
*
|
|
377
|
-
* Buffers across chunk boundaries. Stops at the first '\n'; bytes past
|
|
378
|
-
* it are silently dropped (the wire protocol is one-line-per-connection).
|
|
379
|
-
*
|
|
380
|
-
* Crucially, this does NOT use `for await ... of socket` — that
|
|
381
|
-
* iterator's automatic cleanup destroys the socket on break/return,
|
|
382
|
-
* which would prevent the caller from writing the response back. The
|
|
383
|
-
* event-listener form leaves the socket fully writable.
|
|
384
|
-
*/
|
|
385
|
-
export async function readRequest(stream: DataStream): Promise<Request | undefined> {
|
|
386
|
-
const line = await readLine(stream);
|
|
387
|
-
if (line === undefined) return undefined;
|
|
388
|
-
return decodeRequest(line);
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/** Read one newline-terminated JSON line and decode it as a Response. */
|
|
392
|
-
export async function readResponse(stream: DataStream): Promise<Response | undefined> {
|
|
393
|
-
const line = await readLine(stream);
|
|
394
|
-
if (line === undefined) return undefined;
|
|
395
|
-
return decodeResponse(line);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/** Internal — read up to and including the first '\n' via stream events. */
|
|
399
|
-
async function readLine(stream: DataStream): Promise<string | undefined> {
|
|
400
|
-
return new Promise<string | undefined>((resolve, reject) => {
|
|
401
|
-
const chunks: Buffer[] = [];
|
|
402
|
-
let total = 0;
|
|
403
|
-
let settled = false;
|
|
404
|
-
|
|
405
|
-
const cleanup = (): void => {
|
|
406
|
-
stream.off('data', onData);
|
|
407
|
-
stream.off('end', onEnd);
|
|
408
|
-
stream.off('close', onEnd);
|
|
409
|
-
stream.off('error', onError);
|
|
410
|
-
};
|
|
411
|
-
const settle = (value: string | undefined, err?: Error): void => {
|
|
412
|
-
if (settled) return;
|
|
413
|
-
settled = true;
|
|
414
|
-
cleanup();
|
|
415
|
-
if (err) reject(err);
|
|
416
|
-
else resolve(value);
|
|
417
|
-
};
|
|
418
|
-
const onData = (raw: Buffer): void => {
|
|
419
|
-
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
420
|
-
const nl = buf.indexOf(0x0a);
|
|
421
|
-
if (nl < 0) {
|
|
422
|
-
chunks.push(buf);
|
|
423
|
-
total += buf.length;
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
// Slice everything up to and including the newline; drop trailing
|
|
427
|
-
// bytes in the same chunk (one line per connection).
|
|
428
|
-
const head = Buffer.concat([...chunks, buf.subarray(0, nl + 1)]);
|
|
429
|
-
settle(head.toString('utf8'));
|
|
430
|
-
};
|
|
431
|
-
const onEnd = (): void => {
|
|
432
|
-
if (total === 0) {
|
|
433
|
-
settle(undefined);
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
settle(Buffer.concat(chunks).toString('utf8'));
|
|
437
|
-
};
|
|
438
|
-
const onError = (err: Error): void => settle(undefined, err);
|
|
439
|
-
|
|
440
|
-
stream.on('data', onData);
|
|
441
|
-
stream.on('end', onEnd);
|
|
442
|
-
stream.on('close', onEnd);
|
|
443
|
-
stream.on('error', onError);
|
|
444
|
-
});
|
|
445
|
-
}
|