@moxxy/plugin-channel-web 0.28.1 → 0.29.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.
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, it } from 'vitest';
2
2
  import { WebSocket } from 'ws';
3
3
  import type { ClientSession, MoxxyEvent } from '@moxxy/sdk';
4
+ import { assertDefined } from '@moxxy/sdk';
4
5
  import {
5
6
  freeTcpPortIfMoxxy,
6
7
  WebChannel,
@@ -188,7 +189,8 @@ describe('WebChannel', () => {
188
189
  });
189
190
  handle = await channel.start({ session: fakeSession().session });
190
191
  expect(published).not.toBeNull();
191
- expect(published!.url).toBe('https://abc.trycloudflare.com/?t=tkn');
192
+ assertDefined(published, 'a surface was published');
193
+ expect(published.url).toBe('https://abc.trycloudflare.com/?t=tkn');
192
194
  });
193
195
 
194
196
  it('co-attached to a real Session publishes a localhost URL by default (the TUI case)', async () => {
@@ -206,8 +208,9 @@ describe('WebChannel', () => {
206
208
  });
207
209
  handle = await channel.start({ session: session as never });
208
210
  expect(published).not.toBeNull();
211
+ assertDefined(published, 'a surface was published');
209
212
  // A real, tokenized URL present_view can hand back — never null/rendered:false.
210
- expect(published!.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/\?t=tkn$/);
213
+ expect(published.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/\?t=tkn$/);
211
214
  });
212
215
 
213
216
  it('falls back to the local URL when the tunnel provider fails', async () => {
@@ -227,8 +230,9 @@ describe('WebChannel', () => {
227
230
  });
228
231
  handle = await channel.start({ session: fakeSession().session });
229
232
  expect(published).not.toBeNull();
230
- expect(published!.url).toContain('http://127.0.0.1:');
231
- expect(published!.url).toContain('?t=tkn');
233
+ assertDefined(published, 'a surface was published');
234
+ expect(published.url).toContain('http://127.0.0.1:');
235
+ expect(published.url).toContain('?t=tkn');
232
236
  });
233
237
 
234
238
  it('opens a WS handshake under the relay base path (proxy /web)', async () => {
package/src/channel.ts CHANGED
@@ -122,9 +122,10 @@ export async function freeTcpPortIfMoxxy(
122
122
  );
123
123
  const foreign = holders.filter((h) => !looksLikeMoxxy(h.command));
124
124
  if (foreign.length > 0) {
125
- logger?.warn?.(`port ${port} is held by non-moxxy process(es); not killing them`, {
126
- holders: foreign.map((h) => `${h.pid}: ${h.command || '<unknown command>'}`),
127
- });
125
+ if (logger?.warn)
126
+ logger.warn(`port ${port} is held by non-moxxy process(es); not killing them`, {
127
+ holders: foreign.map((h) => `${h.pid}: ${h.command || '<unknown command>'}`),
128
+ });
128
129
  return false;
129
130
  }
130
131
  // Re-verify identity immediately before each signal. Between the `ps`
@@ -139,7 +140,7 @@ export async function freeTcpPortIfMoxxy(
139
140
  try {
140
141
  // Log the exact command we judged moxxy-owned before signalling, so a
141
142
  // mis-fire on a substring-collision process is auditable after the fact.
142
- logger?.warn?.('freeing port: SIGTERM to apparent stale moxxy process', { pid, command });
143
+ if (logger?.warn) logger.warn('freeing port: SIGTERM to apparent stale moxxy process', { pid, command });
143
144
  deps.kill(pid, 'SIGTERM');
144
145
  attempted = true;
145
146
  } catch {
@@ -158,7 +159,7 @@ export async function freeTcpPortIfMoxxy(
158
159
  const command = await deps.pidCommand(pid);
159
160
  if (!looksLikeMoxxy(command)) continue;
160
161
  try {
161
- logger?.warn?.('freeing port: SIGKILL to apparent stale moxxy process', { pid, command });
162
+ if (logger?.warn) logger.warn('freeing port: SIGKILL to apparent stale moxxy process', { pid, command });
162
163
  deps.kill(pid, 'SIGKILL');
163
164
  } catch {
164
165
  /* dead */
@@ -328,7 +329,10 @@ export class WebChannel implements Channel<WebStartOpts> {
328
329
  wss.on('connection', (ws) => this.onConnection(ws));
329
330
  // Never leave an EventEmitter 'error' unhandled — it would throw at the
330
331
  // process level. Forwarded server errors after bind are log-and-survive.
331
- wss.on('error', (err) => this.logger?.warn?.('web socket server error', { err: String(err) }));
332
+ wss.on('error', (err) => {
333
+ const logger = this.logger;
334
+ if (logger?.warn) logger.warn('web socket server error', { err: String(err) });
335
+ });
332
336
 
333
337
  await this.openTunnel();
334
338
  this.publishSurface?.({ url: this.shareUrl, nextViewId: () => `v_srv_${++this.viewSeq}` });
@@ -358,7 +362,8 @@ export class WebChannel implements Channel<WebStartOpts> {
358
362
  server.off('error', onError);
359
363
  const addr = server.address();
360
364
  if (addr && typeof addr === 'object') this.port = (addr as AddressInfo).port;
361
- this.logger?.info?.('web channel listening', { url: this.url });
365
+ const logger = this.logger;
366
+ if (logger?.info) logger.info('web channel listening', { url: this.url });
362
367
  resolve();
363
368
  };
364
369
  server.once('error', onError);
@@ -376,9 +381,11 @@ export class WebChannel implements Channel<WebStartOpts> {
376
381
  const requested = this.port;
377
382
  const freed = await freeTcpPortIfMoxxy(requested, this.logger).catch(() => false);
378
383
  if (freed) {
379
- this.logger?.warn?.(
380
- `web channel port ${requested} was held by a stale moxxy process; freed it, retrying`,
381
- );
384
+ const logger = this.logger;
385
+ if (logger?.warn)
386
+ logger.warn(
387
+ `web channel port ${requested} was held by a stale moxxy process; freed it, retrying`,
388
+ );
382
389
  try {
383
390
  await tryListen();
384
391
  return;
@@ -392,10 +399,12 @@ export class WebChannel implements Channel<WebStartOpts> {
392
399
  // this.url / shareUrl / the tunnel all carry it automatically.
393
400
  this.port = 0;
394
401
  await tryListen();
395
- this.logger?.warn?.(
396
- `web channel port ${requested} was in use by another process; bound ephemeral port ${this.port} instead`,
397
- { requestedPort: requested, boundPort: this.port, url: this.url },
398
- );
402
+ const logger = this.logger;
403
+ if (logger?.warn)
404
+ logger.warn(
405
+ `web channel port ${requested} was in use by another process; bound ephemeral port ${this.port} instead`,
406
+ { requestedPort: requested, boundPort: this.port, url: this.url },
407
+ );
399
408
  }
400
409
 
401
410
  /**
@@ -422,9 +431,11 @@ export class WebChannel implements Channel<WebStartOpts> {
422
431
  this.tunnel = await provider.open({ port: this.port, host: this.host, label: 'web' });
423
432
  this.tunnelBase = this.tunnel.url;
424
433
  this.basePath = basePathFromUrl(this.tunnel.url);
425
- this.logger?.info?.('web surface tunnel open', { provider: provider.name, url: this.shareUrl });
434
+ const logger = this.logger;
435
+ if (logger?.info) logger.info('web surface tunnel open', { provider: provider.name, url: this.shareUrl });
426
436
  } catch (err) {
427
- this.logger?.warn?.('web surface tunnel failed; using local URL', { provider: provider.name, err: String(err) });
437
+ const logger = this.logger;
438
+ if (logger?.warn) logger.warn('web surface tunnel failed; using local URL', { provider: provider.name, err: String(err) });
428
439
  }
429
440
  }
430
441
 
@@ -622,10 +633,12 @@ export class WebChannel implements Channel<WebStartOpts> {
622
633
  const now = Date.now();
623
634
  if (now - this.lastDropWarnAt < DROP_WARN_INTERVAL_MS) return;
624
635
  this.lastDropWarnAt = now;
625
- this.logger?.warn?.('web channel dropped invalid client frame(s)', {
626
- reason,
627
- droppedTotal: this.droppedFrames,
628
- });
636
+ const logger = this.logger;
637
+ if (logger?.warn)
638
+ logger.warn('web channel dropped invalid client frame(s)', {
639
+ reason,
640
+ droppedTotal: this.droppedFrames,
641
+ });
629
642
  }
630
643
 
631
644
  private async drive(prompt: string): Promise<void> {
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import type { ServiceRegistry } from '@moxxy/sdk';
3
+ import { assertDefined } from '@moxxy/sdk';
3
4
  import { webChannelPlugin } from './index.js';
4
5
 
5
6
  /**
@@ -32,7 +33,11 @@ describe('webChannelPlugin (discovery-loadable)', () => {
32
33
  }
33
34
  });
34
35
  const services = { get, register: () => {}, require: () => undefined, has: () => true } as unknown as ServiceRegistry;
35
- webChannelPlugin.hooks!.onInit!({ services } as never);
36
+ const hooks = webChannelPlugin.hooks;
37
+ assertDefined(hooks, 'plugin defines hooks');
38
+ const onInit = hooks.onInit;
39
+ assertDefined(onInit, 'hooks define onInit');
40
+ onInit({ services } as never);
36
41
  expect(get).toHaveBeenCalledWith('tunnelProviders');
37
42
  expect(get).toHaveBeenCalledWith('viewSurface');
38
43
  expect(get).toHaveBeenCalledWith('webControls');
@@ -51,7 +51,7 @@ export function ChatPanel(props: {
51
51
  useEffect(() => {
52
52
  const opener = typeof document !== 'undefined' ? (document.activeElement as HTMLElement | null) : null;
53
53
  inputRef.current?.focus();
54
- return () => opener?.focus?.();
54
+ return () => opener?.focus();
55
55
  }, []);
56
56
 
57
57
  const onKeyDown = useCallback(
@@ -64,9 +64,9 @@ export function ChatPanel(props: {
64
64
  if (e.key !== 'Tab') return;
65
65
  // Trap Tab within the panel so focus never escapes to the obscured view.
66
66
  const focusables = focusableWithin(panelRef.current);
67
- if (focusables.length === 0) return;
68
- const first = focusables[0]!;
69
- const last = focusables[focusables.length - 1]!;
67
+ const first = focusables[0];
68
+ const last = focusables[focusables.length - 1];
69
+ if (!first || !last) return;
70
70
  const active = document.activeElement;
71
71
  if (e.shiftKey && active === first) {
72
72
  e.preventDefault();
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
2
2
  import { createElement } from 'react';
3
3
  import { renderToStaticMarkup } from 'react-dom/server';
4
4
  import type { FileDiffDisplay } from '@moxxy/sdk';
5
+ import { assertDefined } from '@moxxy/sdk';
5
6
  import { FileDiffView } from './render-diff.js';
6
7
 
7
8
  const render = (display: FileDiffDisplay): string => renderToStaticMarkup(createElement(FileDiffView, { display }));
@@ -58,10 +59,12 @@ describe('FileDiffView', () => {
58
59
  });
59
60
 
60
61
  it('inserts a ⋯ gap row between non-contiguous hunks', () => {
62
+ const hunk = baseDisplay.hunks[0];
63
+ assertDefined(hunk, 'baseDisplay has a hunk');
61
64
  const twoHunks: FileDiffDisplay = {
62
65
  ...baseDisplay,
63
66
  hunks: [
64
- baseDisplay.hunks[0]!,
67
+ hunk,
65
68
  { oldStart: 40, oldLines: 1, newStart: 41, newLines: 1, lines: [{ kind: 'context', text: 'far away', oldNo: 40, newNo: 41 }] },
66
69
  ],
67
70
  };
@@ -110,6 +110,7 @@ export function renderNode(node: ViewNode, h: RenderHandlers, key?: number): Rea
110
110
  case 'link': {
111
111
  // A `to` link navigates client-side; otherwise it is an external anchor.
112
112
  if (node.nav) {
113
+ const nav = node.nav;
113
114
  return (
114
115
  <a
115
116
  className="v-link"
@@ -117,7 +118,7 @@ export function renderNode(node: ViewNode, h: RenderHandlers, key?: number): Rea
117
118
  key={key}
118
119
  onClick={(e) => {
119
120
  e.preventDefault();
120
- h.navigate(node.nav!);
121
+ h.navigate(nav);
121
122
  }}
122
123
  >
123
124
  {kids}
@@ -223,7 +224,10 @@ function gatherFormValues(form: HTMLFormElement | null): Record<string, string>
223
224
  function pick(values: Record<string, string>, fields: ReadonlyArray<string>): Record<string, string> {
224
225
  if (fields.length === 0) return values;
225
226
  const out: Record<string, string> = {};
226
- for (const f of fields) if (f in values) out[f] = values[f]!;
227
+ for (const f of fields) {
228
+ const v = values[f];
229
+ if (v !== undefined) out[f] = v;
230
+ }
227
231
  return out;
228
232
  }
229
233
 
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { ViewDoc } from '@moxxy/sdk';
3
+ import { assertDefined } from '@moxxy/sdk';
3
4
  import { applyView, canGoBack, currentEntry, goBack, initialNav, navigateTo } from './view-store.js';
4
5
 
5
6
  const doc = (tag = 'view'): ViewDoc => ({ root: { kind: 'element', tag, props: {}, children: [] } });
@@ -48,7 +49,8 @@ describe('view-store', () => {
48
49
  s = applyView(s, frame('v2', 'results'));
49
50
  const back = navigateTo(s, 'search');
50
51
  expect(back).not.toBeNull();
51
- expect(currentEntry(back!)?.key).toBe('search');
52
+ assertDefined(back, 'navigateTo returned a cached view');
53
+ expect(currentEntry(back)?.key).toBe('search');
52
54
  expect(navigateTo(s, 'detail')).toBeNull(); // not cached → caller asks the agent
53
55
  });
54
56
 
@@ -78,7 +78,8 @@ function prune(state: NavState): NavState {
78
78
  const order = [...state.order];
79
79
  // Walk oldest→newest, dropping evictable (non-history) keys until within cap.
80
80
  for (let i = 0; i < order.length && order.length > MAX_CACHED_VIEWS; ) {
81
- const key = order[i]!;
81
+ const key = order[i];
82
+ if (key === undefined) break;
82
83
  if (live.has(key)) {
83
84
  i += 1; // pinned by history — skip, keep it
84
85
  continue;
package/src/index.ts CHANGED
@@ -97,7 +97,8 @@ export function buildWebChannelPlugin(opts: BuildWebChannelOptions = {}): Plugin
97
97
  }
98
98
  tunnels.setActive(name);
99
99
  await writeTunnelSetting(name, opts.settingsFile);
100
- const url = (await opts.getControls?.()?.retunnel()) ?? null;
100
+ const controls = opts.getControls?.();
101
+ const url = (await controls?.retunnel()) ?? null;
101
102
  return { ok: true, active: name, ...(url ? { url } : { note: 'applies when the web surface (re)starts' }) };
102
103
  },
103
104
  }),
@@ -166,7 +167,10 @@ export const webChannelPlugin: Plugin = (() => {
166
167
 
167
168
  const tunnels: TunnelControls = {
168
169
  list: () => tp?.list().map((p) => p.name) ?? [],
169
- active: () => tp?.getActive()?.name ?? null,
170
+ active: () => {
171
+ const activeTunnel = tp?.getActive();
172
+ return activeTunnel?.name ?? null;
173
+ },
170
174
  setActive: (n) => {
171
175
  tp?.setActive(n);
172
176
  },
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { MoxxyEvent } from '@moxxy/sdk';
3
+ import { assertDefined } from '@moxxy/sdk';
3
4
  import { EventProjector } from './projector.js';
4
5
 
5
6
  // Minimal event factory — only the fields the projector reads.
@@ -15,7 +16,8 @@ describe('EventProjector', () => {
15
16
  expect(p.project(ev({ type: 'tool_call_requested', callId: 'c1', name: 'present_view', input: { fallbackText: 'fb' } }))).toEqual([]);
16
17
  const frames = p.project(ev({ type: 'tool_result', callId: 'c1', ok: true, output: { ast: sampleDoc } }));
17
18
  expect(frames).toHaveLength(1);
18
- const f = frames[0]!;
19
+ const f = frames[0];
20
+ assertDefined(f, 'projector emitted a view frame');
19
21
  expect(f.kind).toBe('view');
20
22
  if (f.kind !== 'view') return;
21
23
  expect(f.doc).toEqual(sampleDoc);
@@ -26,9 +28,13 @@ describe('EventProjector', () => {
26
28
  it('sets `replaces` to the prior view id on the second view', () => {
27
29
  const p = new EventProjector();
28
30
  p.project(ev({ type: 'tool_call_requested', callId: 'c1', name: 'present_view', input: {} }));
29
- const first = p.project(ev({ type: 'tool_result', callId: 'c1', ok: true, output: { ast: sampleDoc } }))[0]!;
31
+ const firstFrames = p.project(ev({ type: 'tool_result', callId: 'c1', ok: true, output: { ast: sampleDoc } }));
32
+ const first = firstFrames[0];
33
+ assertDefined(first, 'projector emitted a view frame');
30
34
  p.project(ev({ type: 'tool_call_requested', callId: 'c2', name: 'present_view', input: {} }));
31
- const second = p.project(ev({ type: 'tool_result', callId: 'c2', ok: true, output: { ast: sampleDoc } }))[0]!;
35
+ const secondFrames = p.project(ev({ type: 'tool_result', callId: 'c2', ok: true, output: { ast: sampleDoc } }));
36
+ const second = secondFrames[0];
37
+ assertDefined(second, 'projector emitted a view frame');
32
38
  if (first.kind !== 'view' || second.kind !== 'view') throw new Error('expected views');
33
39
  expect(second.replaces).toBe(first.viewId);
34
40
  expect(second.viewId).not.toBe(first.viewId);
@@ -38,14 +44,18 @@ describe('EventProjector', () => {
38
44
  const p = new EventProjector();
39
45
  p.project(ev({ type: 'tool_call_requested', callId: 'c1', name: 'present_view', input: {} }));
40
46
  const namedDoc = { root: { kind: 'element', tag: 'view', props: { name: 'search' }, children: [] } };
41
- const f = p.project(ev({ type: 'tool_result', callId: 'c1', ok: true, output: { ast: namedDoc } }))[0]!;
47
+ const namedFrames = p.project(ev({ type: 'tool_result', callId: 'c1', ok: true, output: { ast: namedDoc } }));
48
+ const f = namedFrames[0];
49
+ assertDefined(f, 'projector emitted a view frame');
42
50
  expect(f.kind === 'view' && f.name).toBe('search');
43
51
  });
44
52
 
45
53
  it('omits name when the view has none', () => {
46
54
  const p = new EventProjector();
47
55
  p.project(ev({ type: 'tool_call_requested', callId: 'c1', name: 'present_view', input: {} }));
48
- const f = p.project(ev({ type: 'tool_result', callId: 'c1', ok: true, output: { ast: sampleDoc } }))[0]!;
56
+ const frames = p.project(ev({ type: 'tool_result', callId: 'c1', ok: true, output: { ast: sampleDoc } }));
57
+ const f = frames[0];
58
+ assertDefined(f, 'projector emitted a view frame');
49
59
  expect(f.kind === 'view' && f.name).toBeUndefined();
50
60
  });
51
61
 
@@ -142,9 +152,13 @@ describe('EventProjector', () => {
142
152
  it('handles two present_view calls in one turn, each replacing the last', () => {
143
153
  const p = new EventProjector();
144
154
  p.project(ev({ type: 'tool_call_requested', callId: 'a', name: 'present_view', input: {} }));
145
- const first = p.project(ev({ type: 'tool_result', callId: 'a', ok: true, output: { ast: sampleDoc } }))[0]!;
155
+ const firstFrames = p.project(ev({ type: 'tool_result', callId: 'a', ok: true, output: { ast: sampleDoc } }));
156
+ const first = firstFrames[0];
157
+ assertDefined(first, 'projector emitted a view frame');
146
158
  p.project(ev({ type: 'tool_call_requested', callId: 'b', name: 'present_view', input: {} }));
147
- const second = p.project(ev({ type: 'tool_result', callId: 'b', ok: true, output: { ast: sampleDoc } }))[0]!;
159
+ const secondFrames = p.project(ev({ type: 'tool_result', callId: 'b', ok: true, output: { ast: sampleDoc } }));
160
+ const second = secondFrames[0];
161
+ assertDefined(second, 'projector emitted a view frame');
148
162
  if (first.kind !== 'view' || second.kind !== 'view') throw new Error('views');
149
163
  expect(second.replaces).toBe(first.viewId);
150
164
  });
@@ -167,6 +181,8 @@ describe('EventProjector', () => {
167
181
  p.project(ev({ type: 'tool_call_requested', callId: 'live', name: 'present_view', input: {} }));
168
182
  const frames = p.project(ev({ type: 'tool_result', callId: 'live', ok: true, output: { ast: sampleDoc } }));
169
183
  expect(frames).toHaveLength(1);
170
- expect(frames[0]!.kind).toBe('view');
184
+ const frame = frames[0];
185
+ assertDefined(frame, 'projector emitted a view frame');
186
+ expect(frame.kind).toBe('view');
171
187
  });
172
188
  });
@@ -94,7 +94,10 @@ describe('web_set_tunnel', () => {
94
94
  });
95
95
 
96
96
  describe('onInit applies the persisted / default tunnel', () => {
97
- const fireInit = (hooks: LifecycleHooks | undefined) => (hooks?.onInit as (() => void) | undefined)?.();
97
+ const fireInit = (hooks: LifecycleHooks | undefined) => {
98
+ const onInit = hooks?.onInit as (() => void) | undefined;
99
+ onInit?.();
100
+ };
98
101
 
99
102
  it('applies a persisted setting on boot', async () => {
100
103
  await writeTunnelSetting('proxy', file);