ohzi-core 13.2.2 → 14.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.
@@ -0,0 +1,114 @@
1
+ import type { Command, Reply } from './protocol';
2
+
3
+ // `unknown` already absorbs Promise<unknown>; run() normalises both via Promise.resolve.
4
+ export type CommandHandler = (args: Record<string, unknown>) => unknown;
5
+ export type HandlerTiming = 'immediate' | 'frame_end';
6
+
7
+ interface Registration
8
+ {
9
+ timing: HandlerTiming;
10
+ handler: CommandHandler;
11
+ }
12
+
13
+ interface QueuedCommand
14
+ {
15
+ id: string;
16
+ args: Record<string, unknown>;
17
+ handler: CommandHandler;
18
+ }
19
+
20
+ class CommandDispatcher
21
+ {
22
+ handlers: Record<string, Registration>;
23
+ queue: QueuedCommand[];
24
+
25
+ private reply: (reply: Reply) => void;
26
+
27
+ constructor(reply: (reply: Reply) => void)
28
+ {
29
+ this.reply = reply;
30
+ this.handlers = {};
31
+ this.queue = [];
32
+ }
33
+
34
+ register(cmd: string, timing: HandlerTiming, handler: CommandHandler)
35
+ {
36
+ this.handlers[cmd] = { timing, handler };
37
+ }
38
+
39
+ handle(command: Command)
40
+ {
41
+ const registration = this.handlers[command.cmd];
42
+ const args = command.args === undefined ? {} : command.args;
43
+
44
+ if (registration === undefined)
45
+ {
46
+ this.reply({
47
+ id: command.id,
48
+ ok: false,
49
+ error: { code: 'unknown_command', message: `Unknown command '${command.cmd}'` }
50
+ });
51
+
52
+ return;
53
+ }
54
+
55
+ if (registration.timing === 'frame_end')
56
+ {
57
+ this.queue.push({ id: command.id, args, handler: registration.handler });
58
+ return;
59
+ }
60
+
61
+ this.run(command.id, args, registration.handler);
62
+ }
63
+
64
+ // Called from MainApplication.on_frame_end() so rendering and mutation
65
+ // never happen part-way through a frame.
66
+ drain()
67
+ {
68
+ const queued = this.queue;
69
+ this.queue = [];
70
+
71
+ for (const command of queued)
72
+ {
73
+ this.run(command.id, command.args, command.handler);
74
+ }
75
+ }
76
+
77
+ private run(id: string, args: Record<string, unknown>, handler: CommandHandler)
78
+ {
79
+ try
80
+ {
81
+ Promise.resolve(handler(args))
82
+ .then((result) => this.reply({ id, ok: true, result }))
83
+ .catch((error) => this.reply({ id, ok: false, error: this.to_error(error) }));
84
+ }
85
+ catch (error)
86
+ {
87
+ this.reply({ id, ok: false, error: this.to_error(error) });
88
+ }
89
+ }
90
+
91
+ private to_error(error: unknown): { code: string; message: string }
92
+ {
93
+ const code = (error as { code?: string })?.code;
94
+ const message = (error as { message?: string })?.message;
95
+
96
+ return {
97
+ code: code === undefined ? 'handler_failed' : code,
98
+ message: typeof message === 'string' ? message : this.describe(error)
99
+ };
100
+ }
101
+
102
+ // Avoids '[object Object]' reaching the developer for a thrown non-Error.
103
+ private describe(error: unknown): string
104
+ {
105
+ if (typeof error === 'string')
106
+ {
107
+ return error;
108
+ }
109
+
110
+ return `Handler failed with a non-Error value of type ${typeof error}`;
111
+ }
112
+ }
113
+
114
+ export { CommandDispatcher };
@@ -0,0 +1,286 @@
1
+ const DEFAULT_CAPACITY = 200;
2
+ const MAX_MESSAGE_LENGTH = 2000;
3
+
4
+ export type ConsoleLevel = 'log' | 'info' | 'warn' | 'error';
5
+
6
+ const LEVELS: ConsoleLevel[] = ['log', 'info', 'warn', 'error'];
7
+
8
+ export interface ConsoleEntry
9
+ {
10
+ seq: number;
11
+ level: ConsoleLevel;
12
+ message: string;
13
+ at: number;
14
+ }
15
+
16
+ export interface ConsoleLike
17
+ {
18
+ log: (...args: unknown[]) => void;
19
+ info: (...args: unknown[]) => void;
20
+ warn: (...args: unknown[]) => void;
21
+ error: (...args: unknown[]) => void;
22
+ }
23
+
24
+ export interface ErrorSource
25
+ {
26
+ addEventListener(type: string, listener: (event: unknown) => void): void;
27
+ removeEventListener(type: string, listener: (event: unknown) => void): void;
28
+ }
29
+
30
+ // Read options arrive off the dev-bridge wire and are therefore untrusted.
31
+ export interface ConsoleReadOptions
32
+ {
33
+ level?: unknown;
34
+ limit?: unknown;
35
+ since?: unknown;
36
+ }
37
+
38
+ export interface ConsoleReadResult
39
+ {
40
+ entries: ConsoleEntry[];
41
+ dropped: number;
42
+ total: number;
43
+ capacity: number;
44
+ }
45
+
46
+ // A bounded log of console output and uncaught failures, so the bridge can
47
+ // report what a screenshot cannot show. Injected console and error source keep
48
+ // this testable outside a browser.
49
+ class ConsoleBuffer
50
+ {
51
+ private capacity: number;
52
+ private entries: ConsoleEntry[];
53
+ private seq: number;
54
+ private dropped: number;
55
+ private clock: () => number;
56
+
57
+ private target: ConsoleLike | null;
58
+ private source: ErrorSource | null;
59
+ private originals: Partial<Record<ConsoleLevel, (...args: unknown[]) => void>>;
60
+ private on_error: ((event: unknown) => void) | null;
61
+ private on_rejection: ((event: unknown) => void) | null;
62
+
63
+ constructor(capacity: number = DEFAULT_CAPACITY, clock: () => number = () => Date.now())
64
+ {
65
+ this.capacity = capacity > 0 ? Math.floor(capacity) : DEFAULT_CAPACITY;
66
+ this.entries = [];
67
+ this.seq = 0;
68
+ this.dropped = 0;
69
+ this.clock = clock;
70
+ this.target = null;
71
+ this.source = null;
72
+ this.originals = {};
73
+ this.on_error = null;
74
+ this.on_rejection = null;
75
+ }
76
+
77
+ install(target: ConsoleLike, source?: ErrorSource)
78
+ {
79
+ this.dispose();
80
+ this.target = target;
81
+
82
+ for (const level of LEVELS)
83
+ {
84
+ // Explicitly typed: indexing with a union then calling .bind() widens to
85
+ // any, which the lint rules reject.
86
+ const original: (...args: unknown[]) => void = target[level];
87
+ this.originals[level] = original;
88
+
89
+ target[level] = (...args: unknown[]) =>
90
+ {
91
+ this.push(level, this.format(args));
92
+ original.apply(target, args);
93
+ };
94
+ }
95
+
96
+ if (source !== undefined)
97
+ {
98
+ this.source = source;
99
+
100
+ this.on_error = (event) => this.push('error', this.event_message(event));
101
+ this.on_rejection = (event) => this.push('error', `Unhandled rejection: ${this.event_reason(event)}`);
102
+
103
+ source.addEventListener('error', this.on_error);
104
+ source.addEventListener('unhandledrejection', this.on_rejection);
105
+ }
106
+ }
107
+
108
+ dispose()
109
+ {
110
+ const target = this.target;
111
+
112
+ if (target !== null)
113
+ {
114
+ for (const level of LEVELS)
115
+ {
116
+ const original = this.originals[level];
117
+
118
+ if (original !== undefined)
119
+ {
120
+ target[level] = original;
121
+ }
122
+ }
123
+ }
124
+
125
+ const source = this.source;
126
+
127
+ if (source !== null)
128
+ {
129
+ if (this.on_error !== null)
130
+ {
131
+ source.removeEventListener('error', this.on_error);
132
+ }
133
+
134
+ if (this.on_rejection !== null)
135
+ {
136
+ source.removeEventListener('unhandledrejection', this.on_rejection);
137
+ }
138
+ }
139
+
140
+ this.originals = {};
141
+ this.target = null;
142
+ this.source = null;
143
+ this.on_error = null;
144
+ this.on_rejection = null;
145
+ }
146
+
147
+ read(options: ConsoleReadOptions): ConsoleReadResult
148
+ {
149
+ const level = typeof options.level === 'string' ? options.level : null;
150
+ const since = typeof options.since === 'number' ? options.since : 0;
151
+ const limit = typeof options.limit === 'number' && options.limit > 0 ? Math.floor(options.limit) : 0;
152
+
153
+ let entries = this.entries.filter((entry) => entry.seq > since);
154
+
155
+ if (level !== null)
156
+ {
157
+ entries = entries.filter((entry) => entry.level === level);
158
+ }
159
+
160
+ if (limit > 0 && entries.length > limit)
161
+ {
162
+ entries = entries.slice(entries.length - limit);
163
+ }
164
+
165
+ return { entries, dropped: this.dropped, total: this.seq, capacity: this.capacity };
166
+ }
167
+
168
+ private push(level: ConsoleLevel, message: string)
169
+ {
170
+ this.seq++;
171
+ this.entries.push({ seq: this.seq, level, message, at: this.clock() });
172
+
173
+ while (this.entries.length > this.capacity)
174
+ {
175
+ this.entries.shift();
176
+ this.dropped++;
177
+ }
178
+ }
179
+
180
+ private format(args: unknown[]): string
181
+ {
182
+ const message = args.map((arg) => this.describe(arg)).join(' ');
183
+
184
+ if (message.length <= MAX_MESSAGE_LENGTH)
185
+ {
186
+ return message;
187
+ }
188
+
189
+ const overflow = message.length - MAX_MESSAGE_LENGTH;
190
+
191
+ return `${message.slice(0, MAX_MESSAGE_LENGTH)} ... [truncated ${overflow} chars]`;
192
+ }
193
+
194
+ // Deliberately exhaustive: a log line rendered as '[object Object]' is worse
195
+ // than no log line at all.
196
+ private describe(value: unknown): string
197
+ {
198
+ if (typeof value === 'string')
199
+ {
200
+ return value;
201
+ }
202
+
203
+ if (value === null)
204
+ {
205
+ return 'null';
206
+ }
207
+
208
+ if (value === undefined)
209
+ {
210
+ return 'undefined';
211
+ }
212
+
213
+ if (typeof value === 'number' || typeof value === 'boolean')
214
+ {
215
+ return String(value);
216
+ }
217
+
218
+ if (typeof value === 'function')
219
+ {
220
+ return '[function]';
221
+ }
222
+
223
+ if (typeof value === 'symbol')
224
+ {
225
+ return '[symbol]';
226
+ }
227
+
228
+ if (typeof value === 'bigint')
229
+ {
230
+ return `${value.toString()}n`;
231
+ }
232
+
233
+ if (value instanceof Error)
234
+ {
235
+ return `${value.name}: ${value.message}`;
236
+ }
237
+
238
+ try
239
+ {
240
+ const json = JSON.stringify(value);
241
+
242
+ return json === undefined ? '[unserialisable]' : json;
243
+ }
244
+ catch
245
+ {
246
+ return '[unserialisable]';
247
+ }
248
+ }
249
+
250
+ private event_message(event: unknown): string
251
+ {
252
+ if (typeof event === 'object' && event !== null)
253
+ {
254
+ const record = event as Record<string, unknown>;
255
+
256
+ if (typeof record.message === 'string')
257
+ {
258
+ return record.message;
259
+ }
260
+
261
+ if (record.error !== undefined)
262
+ {
263
+ return this.describe(record.error);
264
+ }
265
+ }
266
+
267
+ return this.describe(event);
268
+ }
269
+
270
+ private event_reason(event: unknown): string
271
+ {
272
+ if (typeof event === 'object' && event !== null)
273
+ {
274
+ const record = event as Record<string, unknown>;
275
+
276
+ if (record.reason !== undefined)
277
+ {
278
+ return this.describe(record.reason);
279
+ }
280
+ }
281
+
282
+ return this.describe(event);
283
+ }
284
+ }
285
+
286
+ export { ConsoleBuffer };
@@ -0,0 +1,195 @@
1
+ import type { CommandHandler, HandlerTiming } from './CommandDispatcher';
2
+ import { CommandDispatcher } from './CommandDispatcher';
3
+ import type { AppInfo, Command, Reply } from './protocol';
4
+ import { PROTOCOL_VERSION } from './protocol';
5
+
6
+ const RETRY_DELAY_MS = 2000;
7
+
8
+ interface DevBridgeOptions
9
+ {
10
+ port: number;
11
+ app_info: () => AppInfo;
12
+ }
13
+
14
+ class DevBridge
15
+ {
16
+ dispatcher: CommandDispatcher;
17
+
18
+ private socket: WebSocket;
19
+ private options: DevBridgeOptions;
20
+ private retry_timer: number;
21
+ private disposed: boolean;
22
+
23
+ constructor()
24
+ {
25
+ this.socket = null;
26
+ this.options = null;
27
+ this.retry_timer = 0;
28
+ this.disposed = false;
29
+ this.dispatcher = new CommandDispatcher((reply) => this.send_reply(reply));
30
+ }
31
+
32
+ init(options: DevBridgeOptions)
33
+ {
34
+ this.options = options;
35
+ this.disposed = false;
36
+ this.connect();
37
+ }
38
+
39
+ register(cmd: string, timing: HandlerTiming, handler: CommandHandler)
40
+ {
41
+ this.dispatcher.register(cmd, timing, handler);
42
+ }
43
+
44
+ // Called from the application's on_frame_end so queued commands run at a
45
+ // safe frame boundary instead of part-way through a frame.
46
+ on_frame_end()
47
+ {
48
+ this.dispatcher.drain();
49
+ }
50
+
51
+ dispose()
52
+ {
53
+ this.disposed = true;
54
+ window.clearTimeout(this.retry_timer);
55
+
56
+ if (this.socket !== null)
57
+ {
58
+ this.socket.onclose = null;
59
+ this.socket.close();
60
+ this.socket = null;
61
+ }
62
+ }
63
+
64
+ private connect()
65
+ {
66
+ const options = this.options;
67
+
68
+ if (this.disposed || options === null)
69
+ {
70
+ return;
71
+ }
72
+
73
+ const socket = new WebSocket(`ws://127.0.0.1:${options.port}`);
74
+ this.socket = socket;
75
+
76
+ socket.onopen = () =>
77
+ {
78
+ socket.send(JSON.stringify({
79
+ event: 'hello',
80
+ protocol: PROTOCOL_VERSION,
81
+ app: options.app_info()
82
+ }));
83
+ };
84
+
85
+ socket.onmessage = (message) => this.on_message(message);
86
+
87
+ socket.onclose = () =>
88
+ {
89
+ if (this.socket === socket)
90
+ {
91
+ this.socket = null;
92
+ }
93
+
94
+ this.schedule_retry();
95
+ };
96
+
97
+ // A closed host raises both error and close. Retry is driven by close only.
98
+ socket.onerror = () => {};
99
+ }
100
+
101
+ private on_message(message: { data: unknown })
102
+ {
103
+ const raw = message.data;
104
+
105
+ if (typeof raw !== 'string')
106
+ {
107
+ return;
108
+ }
109
+
110
+ const frame = this.parse_frame(raw);
111
+
112
+ if (frame === null)
113
+ {
114
+ return;
115
+ }
116
+
117
+ if (frame.event === 'welcome')
118
+ {
119
+ console.info('[ohzi-mcp] bridge connected');
120
+ return;
121
+ }
122
+
123
+ if (frame.event === 'refused')
124
+ {
125
+ const reason = this.as_text(frame.reason);
126
+ const server_protocol = this.as_text(frame.server_protocol);
127
+
128
+ console.warn(`[ohzi-mcp] bridge refused: ${reason} (server protocol ${server_protocol}, this app speaks ${PROTOCOL_VERSION})`);
129
+ return;
130
+ }
131
+
132
+ if (typeof frame.id === 'string' && typeof frame.cmd === 'string')
133
+ {
134
+ this.dispatcher.handle(frame as unknown as Command);
135
+ }
136
+ }
137
+
138
+ private parse_frame(raw: string): Record<string, unknown> | null
139
+ {
140
+ try
141
+ {
142
+ const value: unknown = JSON.parse(raw);
143
+
144
+ if (typeof value !== 'object' || value === null)
145
+ {
146
+ return null;
147
+ }
148
+
149
+ return value as Record<string, unknown>;
150
+ }
151
+ catch
152
+ {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ // Never let an object reach a template literal as '[object Object]'.
158
+ private as_text(value: unknown): string
159
+ {
160
+ if (typeof value === 'string')
161
+ {
162
+ return value;
163
+ }
164
+
165
+ if (typeof value === 'number' || typeof value === 'boolean')
166
+ {
167
+ return String(value);
168
+ }
169
+
170
+ return '(unknown)';
171
+ }
172
+
173
+ private send_reply(reply: Reply)
174
+ {
175
+ if (this.socket === null || this.socket.readyState !== WebSocket.OPEN)
176
+ {
177
+ return;
178
+ }
179
+
180
+ this.socket.send(JSON.stringify(reply));
181
+ }
182
+
183
+ private schedule_retry()
184
+ {
185
+ if (this.disposed)
186
+ {
187
+ return;
188
+ }
189
+
190
+ window.clearTimeout(this.retry_timer);
191
+ this.retry_timer = window.setTimeout(() => this.connect(), RETRY_DELAY_MS);
192
+ }
193
+ }
194
+
195
+ export { DevBridge };