@oomfware/cbr 0.1.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.
Files changed (46) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +72 -0
  3. package/dist/assets/system-prompt.md +147 -0
  4. package/dist/client.mjs +54 -0
  5. package/dist/index.mjs +1366 -0
  6. package/package.json +45 -0
  7. package/src/assets/system-prompt.md +147 -0
  8. package/src/client.ts +70 -0
  9. package/src/commands/ask.ts +202 -0
  10. package/src/commands/clean.ts +18 -0
  11. package/src/index.ts +34 -0
  12. package/src/lib/commands/_types.ts +24 -0
  13. package/src/lib/commands/_utils.ts +38 -0
  14. package/src/lib/commands/back.ts +14 -0
  15. package/src/lib/commands/check.ts +14 -0
  16. package/src/lib/commands/click.ts +14 -0
  17. package/src/lib/commands/close.ts +17 -0
  18. package/src/lib/commands/dblclick.ts +14 -0
  19. package/src/lib/commands/download.ts +36 -0
  20. package/src/lib/commands/eval.ts +23 -0
  21. package/src/lib/commands/fill.ts +18 -0
  22. package/src/lib/commands/forward.ts +14 -0
  23. package/src/lib/commands/frame.ts +106 -0
  24. package/src/lib/commands/get.ts +95 -0
  25. package/src/lib/commands/hover.ts +14 -0
  26. package/src/lib/commands/is.ts +53 -0
  27. package/src/lib/commands/open.ts +15 -0
  28. package/src/lib/commands/press.ts +13 -0
  29. package/src/lib/commands/reload.ts +14 -0
  30. package/src/lib/commands/resources.ts +37 -0
  31. package/src/lib/commands/screenshot.ts +26 -0
  32. package/src/lib/commands/scroll.ts +30 -0
  33. package/src/lib/commands/select.ts +18 -0
  34. package/src/lib/commands/snapshot.ts +30 -0
  35. package/src/lib/commands/source.ts +23 -0
  36. package/src/lib/commands/styles.ts +63 -0
  37. package/src/lib/commands/tab.ts +102 -0
  38. package/src/lib/commands/type-text.ts +18 -0
  39. package/src/lib/commands/uncheck.ts +14 -0
  40. package/src/lib/commands/wait.ts +93 -0
  41. package/src/lib/commands.ts +202 -0
  42. package/src/lib/debug.ts +11 -0
  43. package/src/lib/paths.ts +118 -0
  44. package/src/lib/server.ts +94 -0
  45. package/src/lib/session.ts +92 -0
  46. package/src/lib/snapshot.ts +351 -0
@@ -0,0 +1,102 @@
1
+ import {
2
+ argument,
3
+ command,
4
+ constant,
5
+ type InferValue,
6
+ integer,
7
+ message,
8
+ object,
9
+ or,
10
+ string,
11
+ } from '@optique/core';
12
+ import { optional } from '@optique/core/modifiers';
13
+
14
+ import { type BrowserState, CommandError } from './_types.ts';
15
+
16
+ export const schema = object({
17
+ command: constant('tab'),
18
+ subcommand: or(
19
+ command(
20
+ 'list',
21
+ object({
22
+ kind: constant('list'),
23
+ }),
24
+ { description: message`list open tabs` },
25
+ ),
26
+ command(
27
+ 'new',
28
+ object({
29
+ kind: constant('new'),
30
+ url: optional(
31
+ argument(string({ metavar: 'URL' }), { description: message`URL to open in the new tab` }),
32
+ ),
33
+ }),
34
+ { description: message`open a new tab and switch to it` },
35
+ ),
36
+ command(
37
+ 'close',
38
+ object({
39
+ kind: constant('close'),
40
+ index: optional(argument(integer({ min: 0 }), { description: message`tab index to close` })),
41
+ }),
42
+ { description: message`close a tab` },
43
+ ),
44
+ // `tab <n>` — switch to tab by index (positional, no subcommand keyword)
45
+ object({
46
+ kind: constant('switch'),
47
+ index: argument(integer({ min: 0 }), { description: message`tab index to switch to` }),
48
+ }),
49
+ ),
50
+ });
51
+
52
+ export type Args = InferValue<typeof schema>;
53
+
54
+ export const handler = async (state: BrowserState, args: Args): Promise<string> => {
55
+ const sub = args.subcommand;
56
+
57
+ switch (sub.kind) {
58
+ case 'list': {
59
+ const pages = state.context.pages();
60
+ const lines = pages.map((p, i) => {
61
+ const marker = p === state.page ? '* ' : ' ';
62
+ return `${marker}${i}: ${p.url()}`;
63
+ });
64
+ return lines.join('\n');
65
+ }
66
+ case 'new': {
67
+ const newPage = await state.context.newPage();
68
+ if (sub.url) {
69
+ await newPage.goto(sub.url, { waitUntil: 'domcontentloaded' });
70
+ }
71
+ state.page = newPage;
72
+ return `opened new tab${sub.url ? ` at ${sub.url}` : ''}`;
73
+ }
74
+ case 'close': {
75
+ const pages = state.context.pages();
76
+
77
+ if (sub.index !== undefined) {
78
+ if (sub.index >= pages.length) {
79
+ throw new CommandError(`invalid tab index: ${sub.index}`);
80
+ }
81
+ await pages[sub.index]!.close();
82
+ // if we closed the current tab, switch to the first available
83
+ if (!state.context.pages().includes(state.page)) {
84
+ state.page = state.context.pages()[0]!;
85
+ }
86
+ } else {
87
+ await state.page.close();
88
+ state.page = state.context.pages()[0]!;
89
+ }
90
+ return 'tab closed';
91
+ }
92
+ case 'switch': {
93
+ const pages = state.context.pages();
94
+ if (sub.index < 0 || sub.index >= pages.length) {
95
+ throw new CommandError(`tab index out of range: ${sub.index} (${pages.length} tabs open)`);
96
+ }
97
+ state.page = pages[sub.index]!;
98
+ await state.page.bringToFront();
99
+ return `switched to tab ${sub.index}: ${state.page.url()}`;
100
+ }
101
+ }
102
+ };
@@ -0,0 +1,18 @@
1
+ import { argument, constant, message, object, string } from '@optique/core';
2
+
3
+ import type { BrowserState } from './_types.ts';
4
+ import { getLocator } from './_utils.ts';
5
+
6
+ export const schema = object({
7
+ command: constant('type'),
8
+ selector: argument(string({ metavar: 'SELECTOR' }), { description: message`element to type into` }),
9
+ text: argument(string({ metavar: 'TEXT' }), { description: message`text to type` }),
10
+ });
11
+
12
+ export const handler = async (
13
+ state: BrowserState,
14
+ args: { selector: string; text: string },
15
+ ): Promise<string> => {
16
+ await getLocator(state, args.selector).pressSequentially(args.text);
17
+ return 'typed';
18
+ };
@@ -0,0 +1,14 @@
1
+ import { argument, constant, object, string } from '@optique/core';
2
+
3
+ import type { BrowserState } from './_types.ts';
4
+ import { getLocator } from './_utils.ts';
5
+
6
+ export const schema = object({
7
+ command: constant('uncheck'),
8
+ selector: argument(string({ metavar: 'SELECTOR' })),
9
+ });
10
+
11
+ export const handler = async (state: BrowserState, args: { selector: string }): Promise<string> => {
12
+ await getLocator(state, args.selector).uncheck();
13
+ return 'unchecked';
14
+ };
@@ -0,0 +1,93 @@
1
+ import {
2
+ argument,
3
+ command,
4
+ constant,
5
+ type InferValue,
6
+ integer,
7
+ message,
8
+ object,
9
+ option,
10
+ or,
11
+ string,
12
+ } from '@optique/core';
13
+ import { withDefault } from '@optique/core/modifiers';
14
+
15
+ import type { BrowserState } from './_types.ts';
16
+ import { getLocator } from './_utils.ts';
17
+
18
+ export const schema = object({
19
+ command: constant('wait'),
20
+ subcommand: or(
21
+ command(
22
+ 'for',
23
+ object({
24
+ kind: constant('for'),
25
+ selector: argument(string({ metavar: 'SELECTOR' })),
26
+ timeout: withDefault(
27
+ option('--timeout', integer({ min: 0 }), { description: message`milliseconds to wait` }),
28
+ 5000,
29
+ ),
30
+ hidden: withDefault(
31
+ option('--hidden', { description: message`wait for the element to disappear` }),
32
+ false,
33
+ ),
34
+ }),
35
+ { description: message`wait for an element to appear` },
36
+ ),
37
+ command(
38
+ 'for-text',
39
+ object({
40
+ kind: constant('for-text'),
41
+ text: argument(string({ metavar: 'TEXT' })),
42
+ timeout: withDefault(
43
+ option('--timeout', integer({ min: 0 }), { description: message`milliseconds to wait` }),
44
+ 5000,
45
+ ),
46
+ hidden: withDefault(
47
+ option('--hidden', { description: message`wait for the text to disappear` }),
48
+ false,
49
+ ),
50
+ }),
51
+ { description: message`wait for text content to appear` },
52
+ ),
53
+ command(
54
+ 'for-url',
55
+ object({
56
+ kind: constant('for-url'),
57
+ pattern: argument(string({ metavar: 'URL_PATTERN' })),
58
+ timeout: withDefault(
59
+ option('--timeout', integer({ min: 0 }), { description: message`milliseconds to wait` }),
60
+ 5000,
61
+ ),
62
+ }),
63
+ { description: message`wait for the URL to match a pattern` },
64
+ ),
65
+ ),
66
+ });
67
+
68
+ export type Args = InferValue<typeof schema>;
69
+
70
+ export const handler = async (state: BrowserState, args: Args): Promise<string> => {
71
+ const start = performance.now();
72
+ const sub = args.subcommand;
73
+
74
+ switch (sub.kind) {
75
+ case 'for': {
76
+ const waitState = sub.hidden ? 'hidden' : 'visible';
77
+ await getLocator(state, sub.selector).waitFor({ state: waitState, timeout: sub.timeout });
78
+ const elapsed = ((performance.now() - start) / 1000).toFixed(1);
79
+ return `element ${sub.selector} is ${waitState} (${elapsed}s)`;
80
+ }
81
+ case 'for-text': {
82
+ const waitState = sub.hidden ? 'hidden' : 'visible';
83
+ await state.page.getByText(sub.text).waitFor({ state: waitState, timeout: sub.timeout });
84
+ const elapsed = ((performance.now() - start) / 1000).toFixed(1);
85
+ return `text "${sub.text}" is ${waitState} (${elapsed}s)`;
86
+ }
87
+ case 'for-url': {
88
+ await state.page.waitForURL(sub.pattern, { timeout: sub.timeout });
89
+ const elapsed = ((performance.now() - start) / 1000).toFixed(1);
90
+ return `url matched ${sub.pattern} (${elapsed}s)`;
91
+ }
92
+ }
93
+ };
@@ -0,0 +1,202 @@
1
+ import { command, formatDocPage, formatMessage, getDocPage, group, message, or, parse } from '@optique/core';
2
+
3
+ import { type BrowserState, CommandError } from './commands/_types.ts';
4
+ import * as back from './commands/back.ts';
5
+ import * as check from './commands/check.ts';
6
+ import * as click from './commands/click.ts';
7
+ import * as close from './commands/close.ts';
8
+ import * as dblclick from './commands/dblclick.ts';
9
+ import * as download from './commands/download.ts';
10
+ import * as eval_ from './commands/eval.ts';
11
+ import * as fill from './commands/fill.ts';
12
+ import * as forward from './commands/forward.ts';
13
+ import * as frame from './commands/frame.ts';
14
+ import * as get from './commands/get.ts';
15
+ import * as hover from './commands/hover.ts';
16
+ import * as is_ from './commands/is.ts';
17
+ import * as open from './commands/open.ts';
18
+ import * as press from './commands/press.ts';
19
+ import * as reload from './commands/reload.ts';
20
+ import * as resources from './commands/resources.ts';
21
+ import * as screenshot from './commands/screenshot.ts';
22
+ import * as scroll from './commands/scroll.ts';
23
+ import * as select from './commands/select.ts';
24
+ import * as snapshot from './commands/snapshot.ts';
25
+ import * as source from './commands/source.ts';
26
+ import * as styles from './commands/styles.ts';
27
+ import * as tab from './commands/tab.ts';
28
+ import * as typeText from './commands/type-text.ts';
29
+ import * as uncheck from './commands/uncheck.ts';
30
+ import * as wait from './commands/wait.ts';
31
+ import type { CommandHandler } from './server.ts';
32
+
33
+ // grouped to stay within or()'s typed overloads (max 10 per call)
34
+ const navigation = group(
35
+ 'navigation',
36
+ or(
37
+ command('open', open.schema, { description: message`navigate to a URL` }),
38
+ command('back', back.schema, { description: message`go back in history` }),
39
+ command('forward', forward.schema, { description: message`go forward in history` }),
40
+ command('reload', reload.schema, { description: message`reload the current page` }),
41
+ command('click', click.schema, { description: message`click an element` }),
42
+ command('dblclick', dblclick.schema, { description: message`double-click an element` }),
43
+ command('fill', fill.schema, { description: message`clear and fill an input field` }),
44
+ command('type', typeText.schema, { description: message`type text character by character` }),
45
+ command('press', press.schema, { description: message`press a keyboard key` }),
46
+ ),
47
+ );
48
+
49
+ const querying = group(
50
+ 'querying',
51
+ or(
52
+ command('hover', hover.schema, { description: message`hover over an element` }),
53
+ command('select', select.schema, { description: message`select a dropdown option` }),
54
+ command('check', check.schema, { description: message`check a checkbox` }),
55
+ command('uncheck', uncheck.schema, { description: message`uncheck a checkbox` }),
56
+ command('get', get.schema, { description: message`get page or element data` }),
57
+ command('is', is_.schema, { description: message`check element state` }),
58
+ command('snapshot', snapshot.schema, { description: message`get the accessibility tree` }),
59
+ command('screenshot', screenshot.schema, { description: message`take a screenshot` }),
60
+ command('wait', wait.schema, { description: message`wait for an element, text, or URL` }),
61
+ ),
62
+ );
63
+
64
+ const inspection = group(
65
+ 'inspection',
66
+ or(
67
+ command('scroll', scroll.schema, { description: message`scroll the page or a container` }),
68
+ command('frame', frame.schema, { description: message`list or switch frames` }),
69
+ command('tab', tab.schema, { description: message`list, open, switch, or close tabs` }),
70
+ command('eval', eval_.schema, { description: message`evaluate JavaScript in the page` }),
71
+ command('source', source.schema, { description: message`get page or element HTML source` }),
72
+ command('resources', resources.schema, { description: message`list loaded resources` }),
73
+ command('styles', styles.schema, { description: message`get computed styles for an element` }),
74
+ command('download', download.schema, { description: message`download a resource to assets/` }),
75
+ command('close', close.schema, { description: message`close the current tab` }),
76
+ ),
77
+ );
78
+
79
+ export const parser = or(navigation, querying, inspection);
80
+
81
+ /**
82
+ * creates a command handler that parses args and dispatches to the matching command.
83
+ * @param state mutable browser state shared across commands
84
+ * @returns a command handler compatible with the server
85
+ */
86
+ export const createCommandHandler =
87
+ (state: BrowserState): CommandHandler =>
88
+ async (args) => {
89
+ if (args[0] === 'help' || args[0] === '--help') {
90
+ const helpArgs = args.slice(1);
91
+ const page = getDocPage(parser, helpArgs);
92
+ if (page) {
93
+ return { ok: true, data: formatDocPage('browser', page) };
94
+ }
95
+ return { ok: false, error: `no help available` };
96
+ }
97
+
98
+ if (state.spinner.isSpinning) {
99
+ state.spinner.text = ['browser', ...args].join(' ').replace(/\s+/g, ' ');
100
+ }
101
+
102
+ const parsed = parse(parser, args);
103
+ if (!parsed.success) {
104
+ return { ok: false, error: formatMessage(parsed.error) };
105
+ }
106
+
107
+ try {
108
+ let data: string | undefined;
109
+
110
+ switch (parsed.value.command) {
111
+ case 'open':
112
+ data = await open.handler(state, parsed.value);
113
+ break;
114
+ case 'back':
115
+ data = await back.handler(state);
116
+ break;
117
+ case 'forward':
118
+ data = await forward.handler(state);
119
+ break;
120
+ case 'reload':
121
+ data = await reload.handler(state);
122
+ break;
123
+ case 'click':
124
+ data = await click.handler(state, parsed.value);
125
+ break;
126
+ case 'dblclick':
127
+ data = await dblclick.handler(state, parsed.value);
128
+ break;
129
+ case 'fill':
130
+ data = await fill.handler(state, parsed.value);
131
+ break;
132
+ case 'type':
133
+ data = await typeText.handler(state, parsed.value);
134
+ break;
135
+ case 'press':
136
+ data = await press.handler(state, parsed.value);
137
+ break;
138
+ case 'hover':
139
+ data = await hover.handler(state, parsed.value);
140
+ break;
141
+ case 'select':
142
+ data = await select.handler(state, parsed.value);
143
+ break;
144
+ case 'check':
145
+ data = await check.handler(state, parsed.value);
146
+ break;
147
+ case 'uncheck':
148
+ data = await uncheck.handler(state, parsed.value);
149
+ break;
150
+ case 'get':
151
+ data = await get.handler(state, parsed.value);
152
+ break;
153
+ case 'is':
154
+ data = await is_.handler(state, parsed.value);
155
+ break;
156
+ case 'snapshot':
157
+ data = await snapshot.handler(state, parsed.value);
158
+ break;
159
+ case 'screenshot':
160
+ data = await screenshot.handler(state, parsed.value);
161
+ break;
162
+ case 'wait':
163
+ data = await wait.handler(state, parsed.value);
164
+ break;
165
+ case 'scroll':
166
+ data = await scroll.handler(state, parsed.value);
167
+ break;
168
+ case 'frame':
169
+ data = await frame.handler(state, parsed.value);
170
+ break;
171
+ case 'tab':
172
+ data = await tab.handler(state, parsed.value);
173
+ break;
174
+ case 'eval':
175
+ data = await eval_.handler(state, parsed.value);
176
+ break;
177
+ case 'source':
178
+ data = await source.handler(state, parsed.value);
179
+ break;
180
+ case 'resources':
181
+ data = await resources.handler(state, parsed.value);
182
+ break;
183
+ case 'styles':
184
+ data = await styles.handler(state, parsed.value);
185
+ break;
186
+ case 'download':
187
+ data = await download.handler(state, parsed.value);
188
+ break;
189
+ case 'close':
190
+ data = await close.handler(state);
191
+ break;
192
+ }
193
+
194
+ return { ok: true, data };
195
+ } catch (e) {
196
+ if (e instanceof CommandError) {
197
+ return { ok: false, error: e.message };
198
+ }
199
+ const message = e instanceof Error ? e.message : String(e);
200
+ return { ok: false, error: message };
201
+ }
202
+ };
@@ -0,0 +1,11 @@
1
+ export const debugEnabled = process.env.DEBUG === '1' || process.env.CBR_DEBUG === '1';
2
+
3
+ /**
4
+ * logs a debug message to stderr if DEBUG=1 or CBR_DEBUG=1.
5
+ * @param message the message to log
6
+ */
7
+ export const debug = (message: string): void => {
8
+ if (debugEnabled) {
9
+ console.error(`[debug] ${message}`);
10
+ }
11
+ };
@@ -0,0 +1,118 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { Dirent } from 'node:fs';
3
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import * as v from 'valibot';
8
+
9
+ /**
10
+ * returns the cache directory for cbr.
11
+ * uses `$XDG_CACHE_HOME/cbr` if set, otherwise falls back to:
12
+ * - `~/.cache/cbr` on linux
13
+ * - `~/Library/Caches/cbr` on macos
14
+ * @returns the cache directory path
15
+ */
16
+ export const getCacheDir = (): string => {
17
+ const xdgCache = process.env['XDG_CACHE_HOME'];
18
+ if (xdgCache) {
19
+ return join(xdgCache, 'cbr');
20
+ }
21
+ const home = homedir();
22
+ if (process.platform === 'darwin') {
23
+ return join(home, 'Library', 'Caches', 'cbr');
24
+ }
25
+ return join(home, '.cache', 'cbr');
26
+ };
27
+
28
+ /**
29
+ * returns the sessions directory within the cache.
30
+ * @returns the sessions directory path
31
+ */
32
+ export const getSessionsDir = (): string => join(getCacheDir(), 'sessions');
33
+
34
+ const PID_FILE = '.pid';
35
+
36
+ // linux PID limits: 1 to 2^22 (4194304) by default, configurable up to 2^22
37
+ const PidSchema = v.pipe(v.string(), v.trim(), v.toNumber(), v.integer(), v.minValue(1));
38
+
39
+ /**
40
+ * creates a new session directory with a random UUID, PID lockfile, and subdirectories.
41
+ * @returns the path to the created session directory
42
+ */
43
+ export const createSessionDir = async (): Promise<string> => {
44
+ const sessionPath = join(getSessionsDir(), randomUUID());
45
+ await mkdir(sessionPath, { recursive: true });
46
+ await writeFile(join(sessionPath, PID_FILE), process.pid.toString());
47
+
48
+ // create subdirectories
49
+ await Promise.all([
50
+ mkdir(join(sessionPath, '.claude'), { recursive: true }),
51
+ mkdir(join(sessionPath, 'bin'), { recursive: true }),
52
+ mkdir(join(sessionPath, 'assets'), { recursive: true }),
53
+ mkdir(join(sessionPath, 'screenshots'), { recursive: true }),
54
+ mkdir(join(sessionPath, 'scratch'), { recursive: true }),
55
+ ]);
56
+
57
+ return sessionPath;
58
+ };
59
+
60
+ /**
61
+ * removes a session directory.
62
+ * @param sessionPath the session directory path
63
+ */
64
+ export const cleanupSessionDir = async (sessionPath: string): Promise<void> => {
65
+ await rm(sessionPath, { recursive: true, force: true });
66
+ };
67
+
68
+ /**
69
+ * checks if a process with the given PID is running.
70
+ * @param pid the process ID to check
71
+ * @returns true if the process is running
72
+ */
73
+ export const isProcessRunning = (pid: number): boolean => {
74
+ try {
75
+ // signal 0 doesn't send a signal but checks if process exists
76
+ process.kill(pid, 0);
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ };
82
+
83
+ /**
84
+ * garbage collects orphaned session directories.
85
+ * a session is orphaned if its PID file is missing or the process is no longer running.
86
+ * this function is meant to be called fire-and-forget (errors are silently ignored).
87
+ */
88
+ export const gcSessions = async (): Promise<void> => {
89
+ const sessionsDir = getSessionsDir();
90
+
91
+ let entries: Dirent[];
92
+ try {
93
+ entries = await readdir(sessionsDir, { withFileTypes: true });
94
+ } catch {
95
+ // sessions dir doesn't exist or can't be read
96
+ return;
97
+ }
98
+
99
+ for (const entry of entries) {
100
+ if (!entry.isDirectory()) {
101
+ continue;
102
+ }
103
+ const sessionPath = join(sessionsDir, entry.name);
104
+ const pidPath = join(sessionPath, PID_FILE);
105
+
106
+ try {
107
+ const pidContent = await readFile(pidPath, 'utf-8');
108
+ const result = v.safeParse(PidSchema, pidContent);
109
+
110
+ if (!result.success || !isProcessRunning(result.output)) {
111
+ await rm(sessionPath, { recursive: true, force: true });
112
+ }
113
+ } catch {
114
+ // no PID file or can't read it - orphaned session
115
+ await rm(sessionPath, { recursive: true, force: true }).catch(() => {});
116
+ }
117
+ }
118
+ };
@@ -0,0 +1,94 @@
1
+ import { createServer, type Server, type Socket } from 'node:net';
2
+
3
+ import * as v from 'valibot';
4
+
5
+ import { debug } from './debug.ts';
6
+
7
+ const RequestSchema = v.object({
8
+ id: v.string(),
9
+ args: v.array(v.string()),
10
+ });
11
+
12
+ export type CommandHandler = (
13
+ args: readonly string[],
14
+ ) => Promise<{ ok: boolean; data?: string; error?: string }>;
15
+
16
+ /**
17
+ * starts a JSON-over-Unix-socket server.
18
+ * each connection handles a single newline-delimited JSON request,
19
+ * dispatches to the handler, writes the response, and closes.
20
+ * @param socketPath path to the Unix domain socket
21
+ * @param handler function that processes commands and returns responses
22
+ * @returns promise that resolves with the server instance once listening
23
+ */
24
+ export const startServer = (socketPath: string, handler: CommandHandler): Promise<Server> => {
25
+ return new Promise((resolve, reject) => {
26
+ const server = createServer((socket: Socket) => {
27
+ let data = '';
28
+
29
+ socket.on('data', (chunk: Buffer) => {
30
+ data += chunk.toString();
31
+
32
+ // process on first newline
33
+ const newlineIndex = data.indexOf('\n');
34
+ if (newlineIndex === -1) {
35
+ return;
36
+ }
37
+
38
+ const line = data.slice(0, newlineIndex).trim();
39
+ // ignore any further data on this connection
40
+ data = '';
41
+
42
+ handleRequest(line, socket, handler);
43
+ });
44
+
45
+ socket.on('error', (err) => {
46
+ debug(`socket error: ${err.message}`);
47
+ });
48
+ });
49
+
50
+ server.on('error', reject);
51
+
52
+ server.listen(socketPath, () => {
53
+ debug(`server listening on ${socketPath}`);
54
+ resolve(server);
55
+ });
56
+ });
57
+ };
58
+
59
+ const handleRequest = async (line: string, socket: Socket, handler: CommandHandler): Promise<void> => {
60
+ let parsed: unknown;
61
+ try {
62
+ parsed = JSON.parse(line);
63
+ } catch {
64
+ debug(`ignoring malformed JSON: ${line}`);
65
+ socket.end();
66
+ return;
67
+ }
68
+
69
+ const result = v.safeParse(RequestSchema, parsed);
70
+ if (!result.success) {
71
+ debug(`ignoring invalid request: ${line}`);
72
+ socket.end();
73
+ return;
74
+ }
75
+
76
+ const { id, args } = result.output;
77
+
78
+ try {
79
+ debug(`request: ${line}`);
80
+
81
+ const result = await handler(args);
82
+ const response = JSON.stringify({ id, ...result });
83
+
84
+ debug(`response: ${response}`);
85
+
86
+ socket.end(response + '\n');
87
+ } catch (err) {
88
+ const message = err instanceof Error ? err.message : String(err);
89
+ const response = JSON.stringify({ id, ok: false, error: message });
90
+
91
+ debug(`handler error: ${message}`);
92
+ socket.end(response + '\n');
93
+ }
94
+ };