@novasamatech/host-api 0.7.9-5 → 0.7.9

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/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export type { ConnectionStatus, HostApiMethod, Logger, RequestHandler, Subscription, SubscriptionHandler, Transport, } from './types.js';
1
+ export type { ConnectionStatus, DebugMessageEvent, HostApiMethod, Logger, RequestHandler, Subscription, SubscriptionHandler, Transport, } from './types.js';
2
+ export type { MessagePayloadSchema } from './protocol/messageCodec.js';
2
3
  export type { Provider } from './provider.js';
3
4
  export { createRequestId } from './helpers.js';
4
5
  export type { HostApi } from './hostApi.js';
package/dist/transport.js CHANGED
@@ -79,6 +79,27 @@ export function createTransport(provider) {
79
79
  const messageProvider = createMessageProvider(provider);
80
80
  // subscriptions management (multiplexing)
81
81
  const activeSubscriptions = new Map();
82
+ // Lazy provider subscription — zero per-message decode cost while no
83
+ // debug listener is attached.
84
+ let debugListenerCount = 0;
85
+ let debugProviderUnsubscribe = null;
86
+ function ensureDebugProviderSubscription() {
87
+ if (debugProviderUnsubscribe)
88
+ return;
89
+ debugProviderUnsubscribe = messageProvider.subscribe(message => {
90
+ events.emit('debugMessage', {
91
+ direction: 'incoming',
92
+ requestId: message.requestId,
93
+ payload: message.payload,
94
+ });
95
+ });
96
+ }
97
+ function maybeDisposeDebugProviderSubscription() {
98
+ if (debugListenerCount > 0)
99
+ return;
100
+ debugProviderUnsubscribe?.();
101
+ debugProviderUnsubscribe = null;
102
+ }
82
103
  const transport = {
83
104
  provider,
84
105
  isCorrectEnvironment() {
@@ -288,6 +309,9 @@ export function createTransport(provider) {
288
309
  },
289
310
  postMessage(requestId, payload) {
290
311
  checks();
312
+ if (debugListenerCount > 0) {
313
+ events.emit('debugMessage', { direction: 'outgoing', requestId, payload });
314
+ }
291
315
  messageProvider.postMessage({ requestId, payload });
292
316
  },
293
317
  listenMessages(action, callback, onError) {
@@ -311,12 +335,44 @@ export function createTransport(provider) {
311
335
  },
312
336
  destroy() {
313
337
  disposed = true;
338
+ debugProviderUnsubscribe?.();
339
+ debugProviderUnsubscribe = null;
340
+ debugListenerCount = 0;
314
341
  provider.dispose();
315
342
  changeConnectionStatus('disconnected');
316
343
  events.emit('destroy');
317
344
  events.events = {};
318
345
  handshakeAbortController.abort('Transport disposed');
319
346
  },
347
+ onDebugMessage(callback) {
348
+ debugListenerCount++;
349
+ ensureDebugProviderSubscription();
350
+ // Wrap each listener individually: nanoevents iterates listeners
351
+ // synchronously and a throw aborts the loop, so without per-listener
352
+ // isolation a single broken listener could starve siblings *and*
353
+ // (on the incoming side) starve unrelated messageProvider subscribers.
354
+ // Route to console.error (not provider.logger.error) so debug-callback
355
+ // bugs stay distinct from real protocol errors — matches the same
356
+ // policy used by host-papp's debugBus.
357
+ const safeCallback = (event) => {
358
+ try {
359
+ callback(event);
360
+ }
361
+ catch (e) {
362
+ console.error('debug listener threw', e);
363
+ }
364
+ };
365
+ const unsubscribe = events.on('debugMessage', safeCallback);
366
+ let disposed = false;
367
+ return () => {
368
+ if (disposed)
369
+ return;
370
+ disposed = true;
371
+ unsubscribe();
372
+ debugListenerCount--;
373
+ maybeDisposeDebugProviderSubscription();
374
+ };
375
+ },
320
376
  };
321
377
  if (provider.isCorrectEnvironment()) {
322
378
  transport.handleRequest('host_handshake', async (version) => {
@@ -1,5 +1,7 @@
1
+ import { enumValue } from '@novasamatech/scale';
1
2
  import { createNanoEvents } from 'nanoevents';
2
3
  import { describe, expect, it, vi } from 'vitest';
4
+ import { JAM_CODEC_PROTOCOL_ID } from './constants.js';
3
5
  import { createDefaultLogger } from './logger.js';
4
6
  import { createTransport } from './transport.js';
5
7
  function createProviders() {
@@ -18,6 +20,7 @@ function createProviders() {
18
20
  sdk: createProvider('toSdk', 'toHost'),
19
21
  };
20
22
  }
23
+ const samplePayload = () => enumValue('host_handshake_request', enumValue('v1', JAM_CODEC_PROTOCOL_ID));
21
24
  describe('transport', () => {
22
25
  describe('subscription', () => {
23
26
  it('should multiplex subscriptions', () => {
@@ -55,4 +58,118 @@ describe('transport', () => {
55
58
  expect(s2Handler).toHaveBeenCalledTimes(2);
56
59
  });
57
60
  });
61
+ describe('debug hook', () => {
62
+ it('emits outgoing events when postMessage is called and still delivers the message', () => {
63
+ const providers = createProviders();
64
+ const host = createTransport(providers.host);
65
+ const sdk = createTransport(providers.sdk);
66
+ const debugListener = vi.fn();
67
+ host.onDebugMessage(debugListener);
68
+ const sdkReceived = vi.fn();
69
+ sdk.listenMessages('host_handshake_request', sdkReceived);
70
+ const requestId = 'req-1';
71
+ const payload = samplePayload();
72
+ host.postMessage(requestId, payload);
73
+ expect(debugListener).toHaveBeenCalledTimes(1);
74
+ expect(debugListener).toHaveBeenCalledWith(expect.objectContaining({ direction: 'outgoing', requestId, payload }));
75
+ expect(sdkReceived).toHaveBeenCalledTimes(1);
76
+ expect(sdkReceived).toHaveBeenCalledWith(requestId, expect.objectContaining({ tag: 'host_handshake_request' }));
77
+ });
78
+ it('emits incoming events with decoded payload', () => {
79
+ const providers = createProviders();
80
+ const host = createTransport(providers.host);
81
+ const sdk = createTransport(providers.sdk);
82
+ const debugListener = vi.fn();
83
+ host.onDebugMessage(debugListener);
84
+ const requestId = 'req-2';
85
+ const payload = samplePayload();
86
+ sdk.postMessage(requestId, payload);
87
+ // host receives sdk's message, plus host's own outgoing handshake
88
+ // attempts (none yet, since isReady() wasn't called). Filter to incoming.
89
+ const incoming = debugListener.mock.calls.map(([event]) => event).filter(e => e.direction === 'incoming');
90
+ expect(incoming).toHaveLength(1);
91
+ expect(incoming[0]).toEqual(expect.objectContaining({
92
+ direction: 'incoming',
93
+ requestId,
94
+ payload: expect.objectContaining({ tag: 'host_handshake_request' }),
95
+ }));
96
+ });
97
+ it('supports multiple listeners and stops after unsubscribe', () => {
98
+ const providers = createProviders();
99
+ const host = createTransport(providers.host);
100
+ const a = vi.fn();
101
+ const b = vi.fn();
102
+ const unsubscribeA = host.onDebugMessage(a);
103
+ host.onDebugMessage(b);
104
+ host.postMessage('req-a', samplePayload());
105
+ expect(a).toHaveBeenCalledTimes(1);
106
+ expect(b).toHaveBeenCalledTimes(1);
107
+ unsubscribeA();
108
+ // calling unsubscribe twice must be a no-op
109
+ unsubscribeA();
110
+ host.postMessage('req-b', samplePayload());
111
+ expect(a).toHaveBeenCalledTimes(1);
112
+ expect(b).toHaveBeenCalledTimes(2);
113
+ });
114
+ it('survives a throwing listener without breaking delivery', () => {
115
+ const providers = createProviders();
116
+ const host = createTransport(providers.host);
117
+ const sdk = createTransport(providers.sdk);
118
+ // Swap console.error directly so the expected throws don't pollute test
119
+ // output. Transport routes debug-callback failures to console.error (not
120
+ // provider.logger) so they stay distinct from real protocol errors.
121
+ const originalConsoleError = console.error;
122
+ const errorSpy = vi.fn();
123
+ console.error = errorSpy;
124
+ try {
125
+ host.onDebugMessage(() => {
126
+ throw new Error('listener boom');
127
+ });
128
+ const goodListener = vi.fn();
129
+ host.onDebugMessage(goodListener);
130
+ const sdkReceived = vi.fn();
131
+ sdk.listenMessages('host_handshake_request', sdkReceived);
132
+ // outgoing: a throwing listener must not block messageProvider.postMessage
133
+ host.postMessage('out-1', samplePayload());
134
+ expect(sdkReceived).toHaveBeenCalledTimes(1);
135
+ // incoming: a throwing listener must not block other host listenMessages subscribers
136
+ const hostReceived = vi.fn();
137
+ host.listenMessages('host_handshake_request', hostReceived);
138
+ sdk.postMessage('in-1', samplePayload());
139
+ expect(hostReceived).toHaveBeenCalledTimes(1);
140
+ // the second good listener still fired despite the first one throwing
141
+ expect(goodListener).toHaveBeenCalled();
142
+ // and the throws were observed on console.error, not propagated
143
+ expect(errorSpy).toHaveBeenCalled();
144
+ }
145
+ finally {
146
+ console.error = originalConsoleError;
147
+ }
148
+ });
149
+ it('cleans up the debug subscription on destroy() and blocks further sends', () => {
150
+ const providers = createProviders();
151
+ const host = createTransport(providers.host);
152
+ const sdk = createTransport(providers.sdk);
153
+ const listener = vi.fn();
154
+ host.onDebugMessage(listener);
155
+ host.destroy();
156
+ // postMessage on a destroyed transport throws
157
+ expect(() => host.postMessage('after-destroy', samplePayload())).toThrow(/Transport is disposed/);
158
+ // incoming traffic from the peer no longer surfaces to the listener
159
+ sdk.postMessage('in-after-destroy', samplePayload());
160
+ expect(listener).not.toHaveBeenCalled();
161
+ });
162
+ it('does not emit outgoing events when no listener is attached', () => {
163
+ const providers = createProviders();
164
+ const host = createTransport(providers.host);
165
+ // sanity: no listener attached, postMessage works fine
166
+ expect(() => host.postMessage('req', samplePayload())).not.toThrow();
167
+ // attach + detach + send: no events should fire to the (now-detached) listener
168
+ const listener = vi.fn();
169
+ const unsubscribe = host.onDebugMessage(listener);
170
+ unsubscribe();
171
+ host.postMessage('req2', samplePayload());
172
+ expect(listener).not.toHaveBeenCalled();
173
+ });
174
+ });
58
175
  });
package/dist/types.d.ts CHANGED
@@ -19,6 +19,16 @@ export type MessageProvider = {
19
19
  postMessage(message: CodecType<typeof Message>): void;
20
20
  subscribe(fn: (message: CodecType<typeof Message>) => void): VoidFunction;
21
21
  };
22
+ /**
23
+ * EXPERIMENTAL. A single message observed on the transport, in its
24
+ * decoded (non-SCALE) form. Intended for host-side introspection.
25
+ */
26
+ export type DebugMessageEvent = {
27
+ /** `outgoing` = sent by this side via `postMessage`; `incoming` = received from the peer. */
28
+ direction: 'incoming' | 'outgoing';
29
+ requestId: string;
30
+ payload: MessagePayloadSchema;
31
+ };
22
32
  export type Transport = {
23
33
  readonly provider: Provider;
24
34
  isCorrectEnvironment(): boolean;
@@ -32,4 +42,12 @@ export type Transport = {
32
42
  handleSubscription<const Method extends HostApiMethod>(method: Method, handler: SubscriptionHandler<Method>): VoidFunction;
33
43
  postMessage(requestId: string, payload: MessagePayloadSchema): void;
34
44
  listenMessages<const Action extends MessageAction>(action: Action, callback: (requestId: string, data: PickMessagePayload<Action>) => void, onError?: (error: unknown) => void): VoidFunction;
45
+ /**
46
+ * EXPERIMENTAL. Subscribe to every message crossing this transport
47
+ * in either direction, in decoded form. Returns an unsubscribe
48
+ * function. Multiple listeners are supported; the underlying
49
+ * provider is subscribed lazily — there is no per-message cost
50
+ * while no listener is attached.
51
+ */
52
+ onDebugMessage(callback: (event: DebugMessageEvent) => void): VoidFunction;
35
53
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@novasamatech/host-api",
3
3
  "type": "module",
4
- "version": "0.7.9-5",
4
+ "version": "0.7.9",
5
5
  "description": "Host API: transport implementation for host - product integration.",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -22,7 +22,7 @@
22
22
  "README.md"
23
23
  ],
24
24
  "dependencies": {
25
- "@novasamatech/scale": "0.7.9-5",
25
+ "@novasamatech/scale": "0.7.9",
26
26
  "nanoevents": "9.1.0",
27
27
  "nanoid": "5.1.9",
28
28
  "neverthrow": "^8.2.0",