@parity/product-sdk-host 0.16.0 → 0.18.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/src/transport.ts CHANGED
@@ -3,18 +3,21 @@
3
3
  /**
4
4
  * Access to the in-house TruAPI client (`@parity/truapi`) for the host package.
5
5
  *
6
- * Environment detection and the lazily-built, cached client come from
7
- * `@parity/truapi/sandbox`; this module layers the product-sdk-specific glue on
8
- * top — an async {@link getClient} accessor and {@link subscribeWithInterrupt},
9
- * which adapts a truapi stream into the host's {@link HostSubscription} shape.
6
+ * Environment detection, the lazily-built cached client, and the connection-status
7
+ * signal come from `@parity/truapi/sandbox`; this module layers the
8
+ * product-sdk-specific glue on top — an async {@link getClient} accessor,
9
+ * {@link subscribeConnectionStatus}, and {@link subscribeWithInterrupt}, which
10
+ * adapts a truapi stream into the host's {@link HostSubscription} shape.
10
11
  *
11
12
  * @module
12
13
  */
13
14
 
14
15
  import type { ObservableLike, TrUApiClient } from "@parity/truapi";
15
16
  import {
17
+ type ConnectionStatus,
16
18
  getClientSync as sandboxGetClientSync,
17
19
  isCorrectEnvironment as sandboxIsCorrectEnvironment,
20
+ subscribeConnectionStatus as sandboxSubscribeConnectionStatus,
18
21
  } from "@parity/truapi/sandbox";
19
22
 
20
23
  import type { HostSubscription } from "./types.js";
@@ -30,6 +33,11 @@ export interface TransportSubscription extends HostSubscription {
30
33
  // no-ops there.
31
34
  let clientOverride: TrUApiClient | null = null;
32
35
 
36
+ // Status subscribers registered here rather than only in the sandbox, so that
37
+ // flipping the test seam is an *event*. The sandbox tracks only the client it
38
+ // built itself, so it cannot know an injected client appeared or went away.
39
+ const localStatusListeners = new Set<(status: HostConnectionStatus) => void>();
40
+
33
41
  function isProductionBuild(): boolean {
34
42
  try {
35
43
  // Must stay a plain `process.env.NODE_ENV` member expression: bundlers
@@ -51,6 +59,9 @@ function isProductionBuild(): boolean {
51
59
  * Calling this in a production build silently reroutes every host accessor to
52
60
  * the injected client, so we warn — it almost always means a `/testing` import
53
61
  * leaked into a production path.
62
+ *
63
+ * Injecting or clearing notifies {@link subscribeConnectionStatus} subscribers,
64
+ * so a product's "host lost" path can be exercised by disposing the fake host.
54
65
  */
55
66
  export function setTruApiClient(client: TrUApiClient | null): void {
56
67
  if (client !== null && isProductionBuild()) {
@@ -58,7 +69,11 @@ export function setTruApiClient(client: TrUApiClient | null): void {
58
69
  "[product-sdk] setTruApiClient() was called in a production build. This is a test-only seam from @parity/product-sdk-host/testing; a leaked import will silently reroute all host access to the injected client.",
59
70
  );
60
71
  }
72
+ const wasOverridden = clientOverride !== null;
61
73
  clientOverride = client;
74
+ if (wasOverridden !== (client !== null)) {
75
+ notifyLocalStatusListeners(client !== null ? "connected" : "disconnected");
76
+ }
62
77
  }
63
78
 
64
79
  /**
@@ -85,6 +100,103 @@ export async function getClient(): Promise<TrUApiClient | null> {
85
100
  return getClientSync();
86
101
  }
87
102
 
103
+ /**
104
+ * Connection lifecycle of the host channel: `"connecting"` while the client waits
105
+ * for the host, `"connected"` once the channel is established, `"disconnected"`
106
+ * outside a host container or after the channel closes.
107
+ *
108
+ * Not the same concept as `@parity/product-sdk-signer`'s identically-shaped
109
+ * `ConnectionStatus`, which tracks a signer provider rather than the transport.
110
+ */
111
+ export type HostConnectionStatus = ConnectionStatus;
112
+
113
+ /**
114
+ * Correct one defect in the sandbox's status signal: `@parity/truapi` never clears
115
+ * its cached client when the pipe closes, so a subscriber arriving after a
116
+ * disconnect re-derives `"connecting"` from the dead client — and because the
117
+ * sandbox fans every change out to all listeners, that rewrites everyone's state
118
+ * with no way back. Hold `"disconnected"` until a real `"connected"` arrives.
119
+ *
120
+ * Applies to sandbox-sourced statuses only. A status pushed by the test seam is
121
+ * deliberate and passes through, so a fake host can still drive a reconnect.
122
+ *
123
+ * Outstanding upstream, not tied to the version we happen to be on: `sandbox.js`
124
+ * is byte-identical from 0.7.0 through 0.9.0 (npm latest) and still unfixed on
125
+ * `paritytech/host-rust-core` main, the repo formerly named truapi. Remove once
126
+ * it clears the cached client on close.
127
+ */
128
+ function latchDisconnected(
129
+ previous: HostConnectionStatus | null,
130
+ next: HostConnectionStatus,
131
+ ): HostConnectionStatus {
132
+ return next === "connecting" && previous === "disconnected" ? "disconnected" : next;
133
+ }
134
+
135
+ function notifyLocalStatusListeners(status: HostConnectionStatus): void {
136
+ // Iterate a snapshot: a listener that unsubscribes itself, or re-enters
137
+ // `setTruApiClient`, must not mutate the set mid-loop.
138
+ for (const listener of [...localStatusListeners]) listener(status);
139
+ }
140
+
141
+ /**
142
+ * Test-only: push `status` to every {@link subscribeConnectionStatus} subscriber,
143
+ * so a product can exercise its reconnecting / offline UI. The host-side
144
+ * counterpart of `@parity/product-sdk-signer`'s `FakeSignerProvider.emitStatus`.
145
+ * Exposed through `@parity/product-sdk-host/testing`, not the main entry.
146
+ */
147
+ export function emitConnectionStatus(status: HostConnectionStatus): void {
148
+ if (isProductionBuild()) {
149
+ console.warn(
150
+ "[product-sdk] emitConnectionStatus() was called in a production build. This is a test-only seam from @parity/product-sdk-host/testing; a leaked import will report a fabricated connection status to real subscribers.",
151
+ );
152
+ }
153
+ notifyLocalStatusListeners(status);
154
+ }
155
+
156
+ /**
157
+ * Subscribe to host-channel connection status. The callback fires synchronously
158
+ * with the current status and again on every change; the returned function
159
+ * unsubscribes. Repeats of the status you already have are suppressed.
160
+ *
161
+ * This is the **transport** channel. For the host's account-level connection —
162
+ * what drives `@parity/product-sdk-signer`'s `ConnectionStatus` — use
163
+ * `AccountsProvider.subscribeAccountConnectionStatus` instead.
164
+ *
165
+ * Subscribing is not passive: outside an established channel the first subscribe
166
+ * builds the client and provider, so this can be what constructs the transport.
167
+ *
168
+ * Honours the `setTruApiClient` seam — an injected client is connected by
169
+ * definition, and injecting or clearing one notifies live subscribers.
170
+ */
171
+ export function subscribeConnectionStatus(
172
+ callback: (status: HostConnectionStatus) => void,
173
+ ): () => void {
174
+ let last: HostConnectionStatus | null = null;
175
+
176
+ // One wrapped callback for both sources, so `last` stays coherent: the seam
177
+ // and the sandbox must not each keep their own idea of what was delivered.
178
+ const deliver = (status: HostConnectionStatus, fromSandbox: boolean): void => {
179
+ const next = fromSandbox ? latchDisconnected(last, status) : status;
180
+ if (next === last) return;
181
+ last = next;
182
+ callback(next);
183
+ };
184
+
185
+ const onLocal = (status: HostConnectionStatus) => deliver(status, false);
186
+ localStatusListeners.add(onLocal);
187
+
188
+ if (clientOverride !== null) {
189
+ onLocal("connected");
190
+ return () => void localStatusListeners.delete(onLocal);
191
+ }
192
+
193
+ const unsubscribeSandbox = sandboxSubscribeConnectionStatus((status) => deliver(status, true));
194
+ return () => {
195
+ localStatusListeners.delete(onLocal);
196
+ unsubscribeSandbox();
197
+ };
198
+ }
199
+
88
200
  /**
89
201
  * Adapt a truapi `ObservableLike` stream into the host's callback-style
90
202
  * {@link HostSubscription} (`unsubscribe` + `onInterrupt`). `onNext` fires for
@@ -163,6 +275,118 @@ if (import.meta.vitest) {
163
275
  }
164
276
  });
165
277
 
278
+ test("subscribeConnectionStatus reports disconnected outside a container", () => {
279
+ const statuses: HostConnectionStatus[] = [];
280
+
281
+ const unsubscribe = subscribeConnectionStatus((status) => statuses.push(status));
282
+
283
+ expect(statuses).toEqual(["disconnected"]);
284
+ unsubscribe();
285
+ });
286
+
287
+ test("subscribeConnectionStatus reports connected for an injected client", () => {
288
+ setTruApiClient({} as TrUApiClient);
289
+ const statuses: HostConnectionStatus[] = [];
290
+
291
+ const unsubscribe = subscribeConnectionStatus((status) => statuses.push(status));
292
+
293
+ // The sandbox only tracks the client it built itself, so without the
294
+ // override branch this would report "disconnected" while every other
295
+ // accessor resolved the injected client.
296
+ expect(statuses).toEqual(["connected"]);
297
+ unsubscribe();
298
+ });
299
+
300
+ test("disposing the injected client notifies live subscribers", () => {
301
+ setTruApiClient({} as TrUApiClient);
302
+ const statuses: HostConnectionStatus[] = [];
303
+ const unsubscribe = subscribeConnectionStatus((status) => statuses.push(status));
304
+
305
+ setTruApiClient(null);
306
+
307
+ expect(statuses).toEqual(["connected", "disconnected"]);
308
+ unsubscribe();
309
+ });
310
+
311
+ test("injecting a client notifies subscribers that started without one", () => {
312
+ const statuses: HostConnectionStatus[] = [];
313
+ const unsubscribe = subscribeConnectionStatus((status) => statuses.push(status));
314
+
315
+ setTruApiClient({} as TrUApiClient);
316
+
317
+ expect(statuses).toEqual(["disconnected", "connected"]);
318
+ unsubscribe();
319
+ });
320
+
321
+ test("unsubscribe stops seam notifications", () => {
322
+ setTruApiClient({} as TrUApiClient);
323
+ const statuses: HostConnectionStatus[] = [];
324
+ const unsubscribe = subscribeConnectionStatus((status) => statuses.push(status));
325
+
326
+ unsubscribe();
327
+ setTruApiClient(null);
328
+
329
+ expect(statuses).toEqual(["connected"]);
330
+ expect(localStatusListeners.size).toBe(0);
331
+ });
332
+
333
+ test("emitConnectionStatus drives transitions, including a reconnect", () => {
334
+ setTruApiClient({} as TrUApiClient);
335
+ const statuses: HostConnectionStatus[] = [];
336
+ const unsubscribe = subscribeConnectionStatus((status) => statuses.push(status));
337
+
338
+ emitConnectionStatus("disconnected");
339
+ // A seam-pushed "connecting" after "disconnected" is deliberate, so it must
340
+ // survive the sandbox latch — otherwise no test could drive a reconnect.
341
+ emitConnectionStatus("connecting");
342
+ emitConnectionStatus("connected");
343
+
344
+ expect(statuses).toEqual(["connected", "disconnected", "connecting", "connected"]);
345
+ unsubscribe();
346
+ });
347
+
348
+ test("repeats of the current status are suppressed", () => {
349
+ setTruApiClient({} as TrUApiClient);
350
+ const statuses: HostConnectionStatus[] = [];
351
+ const unsubscribe = subscribeConnectionStatus((status) => statuses.push(status));
352
+
353
+ emitConnectionStatus("connected");
354
+ emitConnectionStatus("connected");
355
+
356
+ expect(statuses).toEqual(["connected"]);
357
+ unsubscribe();
358
+ });
359
+
360
+ test("a listener that unsubscribes itself mid-notification is safe", () => {
361
+ setTruApiClient({} as TrUApiClient);
362
+ const statuses: HostConnectionStatus[] = [];
363
+ const handle: { unsubscribe?: () => void } = {};
364
+ handle.unsubscribe = subscribeConnectionStatus((status) => {
365
+ statuses.push(status);
366
+ handle.unsubscribe?.();
367
+ });
368
+
369
+ setTruApiClient(null);
370
+
371
+ expect(statuses).toEqual(["connected", "disconnected"]);
372
+ expect(localStatusListeners.size).toBe(0);
373
+ });
374
+
375
+ // The sandbox latch can't be driven through the public surface — it needs a
376
+ // real provider close — so the correction is pinned as a pure function.
377
+ test("latchDisconnected holds disconnected through the stale-cache connecting", () => {
378
+ expect(latchDisconnected("disconnected", "connecting")).toBe("disconnected");
379
+ });
380
+
381
+ test("latchDisconnected passes every other transition through", () => {
382
+ expect(latchDisconnected(null, "disconnected")).toBe("disconnected");
383
+ expect(latchDisconnected(null, "connecting")).toBe("connecting");
384
+ expect(latchDisconnected("connecting", "connected")).toBe("connected");
385
+ expect(latchDisconnected("connected", "disconnected")).toBe("disconnected");
386
+ // A genuine reconnect still gets through once the channel re-establishes.
387
+ expect(latchDisconnected("connected", "connecting")).toBe("connecting");
388
+ });
389
+
166
390
  test("subscribeWithInterrupt preserves the transport subscription id", () => {
167
391
  const observable = {
168
392
  subscribe: () => ({
package/src/truapi.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  type HostError,
29
29
  type HostErrorPayload,
30
30
  HostCallFailedError,
31
+ HostResponseDecodeError,
31
32
  HostUnavailableError,
32
33
  formatHostError,
33
34
  } from "./errors.js";
@@ -37,6 +38,66 @@ import type { HostSubscription, Statement, StatementProof } from "./types.js";
37
38
 
38
39
  const log = createLogger("host");
39
40
 
41
+ /**
42
+ * `result.match(onOk, onErr)`, but a decode-time rejection is routed through
43
+ * `onDecode` instead of rejecting the returned promise.
44
+ *
45
+ * The truapi client decodes each response inside the value it resolves, and
46
+ * wraps the whole call with `ResultAsync.fromSafePromise`, which installs no
47
+ * rejection handler. So when the host's reply doesn't match the client's codec
48
+ * — a protocol-version skew, or a channel that closed mid-call — the resulting
49
+ * rejection escapes the `Result` channel entirely and `.match` never sees it,
50
+ * surfacing as a raw `RangeError` rather than reaching `onErr`. Catching the
51
+ * `.match` promise re-homes that rejection as a typed
52
+ * {@link HostResponseDecodeError} that names the call. Both
53
+ * {@link unwrapHostResult} and {@link mapHostResult} route through here, so
54
+ * every boundary — throwing and Result-returning — is covered, not just the
55
+ * accounts adapter.
56
+ */
57
+ async function matchGuarded<T, E, A, B>(
58
+ result: ResultAsync<T, E>,
59
+ label: string,
60
+ onOk: (value: T) => A,
61
+ onErr: (error: E) => B,
62
+ onDecode: (error: HostResponseDecodeError) => B,
63
+ ): Promise<A | B> {
64
+ // `.match` rejects for two reasons: the underlying `ResultAsync` rejected (a
65
+ // decode failure — what we want to catch), or `onOk`/`onErr` themselves threw
66
+ // (e.g. `unwrapHostResult`'s err path deliberately throws). Wrap the handler
67
+ // throws in a sentinel so the `catch` can tell them apart and only re-home a
68
+ // genuine underlying rejection; a handler throw is rethrown unchanged.
69
+ try {
70
+ return await result.match(
71
+ (value) => {
72
+ try {
73
+ return onOk(value);
74
+ } catch (thrown) {
75
+ throw new HandlerThrow(thrown);
76
+ }
77
+ },
78
+ (error) => {
79
+ try {
80
+ return onErr(error);
81
+ } catch (thrown) {
82
+ throw new HandlerThrow(thrown);
83
+ }
84
+ },
85
+ );
86
+ } catch (cause) {
87
+ if (cause instanceof HandlerThrow) throw cause.thrown;
88
+ return onDecode(
89
+ cause instanceof HostResponseDecodeError
90
+ ? cause
91
+ : new HostResponseDecodeError(label, cause),
92
+ );
93
+ }
94
+ }
95
+
96
+ /** Marks a throw that came from a caller's `onOk`/`onErr`, not the underlying `ResultAsync`. */
97
+ class HandlerThrow {
98
+ constructor(readonly thrown: unknown) {}
99
+ }
100
+
40
101
  /**
41
102
  * Await a host `ResultAsync`, returning its Ok value or throwing a diagnostic
42
103
  * `Error` built from the host's error payload (preserved as `cause`).
@@ -49,11 +110,18 @@ const log = createLogger("host");
49
110
  * throw convention. The flat public operations use {@link mapHostResult} instead.
50
111
  */
51
112
  export function unwrapHostResult<T, E>(result: ResultAsync<T, E>, label: string): Promise<T> {
52
- return result.match(
113
+ return matchGuarded(
114
+ result,
115
+ label,
53
116
  (value) => value,
54
117
  (error: E) => {
55
118
  throw new Error(`${label}: ${formatHostError(error)}`, { cause: error });
56
119
  },
120
+ // A response the client can't decode would otherwise reject with a raw
121
+ // `RangeError`; throw it as a typed, named error instead.
122
+ (decodeError) => {
123
+ throw decodeError;
124
+ },
57
125
  );
58
126
  }
59
127
 
@@ -69,9 +137,15 @@ export function mapHostResult<T, U>(
69
137
  map: (value: T) => U,
70
138
  label: string,
71
139
  ): Promise<Result<U, HostError>> {
72
- return result.match(
140
+ // A response the client can't decode would otherwise reject this promise
141
+ // with a raw `RangeError`; return it as a typed err instead, matching the
142
+ // `Result` contract these flat public operations advertise.
143
+ return matchGuarded<T, HostErrorPayload, Result<U, HostError>, Result<U, HostError>>(
144
+ result,
145
+ label,
73
146
  (value) => ok(map(value)),
74
147
  (error) => err(new HostCallFailedError(label, error)),
148
+ (decodeError) => err(decodeError),
75
149
  );
76
150
  }
77
151
 
@@ -271,7 +345,7 @@ export interface ResultAsync<T, E> {
271
345
  // ─────────────────────────────────────────────────────────────────────────────
272
346
 
273
347
  if (import.meta.vitest) {
274
- const { test, expect } = import.meta.vitest;
348
+ const { test, expect, describe } = import.meta.vitest;
275
349
 
276
350
  test("getTruApi returns null outside a container", async () => {
277
351
  const api = await getTruApi();
@@ -310,4 +384,66 @@ if (import.meta.vitest) {
310
384
  test("createProofAuthorized is callable", () => {
311
385
  expect(typeof createProofAuthorized).toBe("function");
312
386
  });
387
+
388
+ // The decode boundary these two helpers share. The truapi client's real
389
+ // `ResultAsync` *rejects* its underlying promise on a decode failure, which
390
+ // `.match` surfaces as a rejected promise — modelled here by a fake whose
391
+ // `.match` rejects. Ok and typed-err doubles mirror neverthrow's `.match`.
392
+ const okLike = <T>(value: T): ResultAsync<T, never> => ({
393
+ match: async (onOk) => onOk(value),
394
+ });
395
+ const errLike = <E>(error: E): ResultAsync<never, E> => ({
396
+ match: async (_onOk, onErr) => onErr(error),
397
+ });
398
+ const rejectLike = (cause: unknown): ResultAsync<never, never> => ({
399
+ match: () => Promise.reject(cause),
400
+ });
401
+
402
+ describe("mapHostResult decode boundary", () => {
403
+ test("a decode rejection becomes err(HostResponseDecodeError) naming the call", async () => {
404
+ const cause = new RangeError("Offset is outside the bounds of the DataView");
405
+ const result = await mapHostResult(rejectLike(cause), (v) => v, "createRingVRFProof");
406
+ expect(result.ok).toBe(false);
407
+ if (!result.ok) {
408
+ expect(result.error).toBeInstanceOf(HostResponseDecodeError);
409
+ expect((result.error as HostResponseDecodeError).call).toBe("createRingVRFProof");
410
+ expect((result.error as HostResponseDecodeError).cause).toBe(cause);
411
+ }
412
+ });
413
+
414
+ test("an ok value maps through", async () => {
415
+ const result = await mapHostResult(okLike(41), (v: number) => v + 1, "getUserId");
416
+ expect(result.ok && result.value).toBe(42);
417
+ });
418
+
419
+ test("a typed host err becomes HostCallFailedError, not a decode error", async () => {
420
+ const result = await mapHostResult(errLike({ tag: "Denied" }), (v) => v, "getUserId");
421
+ expect(result.ok).toBe(false);
422
+ if (!result.ok) {
423
+ expect(result.error).toBeInstanceOf(HostCallFailedError);
424
+ expect(result.error).not.toBeInstanceOf(HostResponseDecodeError);
425
+ }
426
+ });
427
+ });
428
+
429
+ describe("unwrapHostResult decode boundary", () => {
430
+ test("a decode rejection throws HostResponseDecodeError naming the call", async () => {
431
+ const cause = new RangeError("Offset is outside the bounds of the DataView");
432
+ await expect(unwrapHostResult(rejectLike(cause), "signVrf")).rejects.toMatchObject({
433
+ name: "HostResponseDecodeError",
434
+ call: "signVrf",
435
+ cause,
436
+ });
437
+ });
438
+
439
+ test("an ok value passes through", async () => {
440
+ expect(await unwrapHostResult(okLike("hi"), "getUserId")).toBe("hi");
441
+ });
442
+
443
+ test("a typed host err still throws a diagnostic Error, not a decode error", async () => {
444
+ await expect(
445
+ unwrapHostResult(errLike({ tag: "Denied" }), "getUserId"),
446
+ ).rejects.not.toBeInstanceOf(HostResponseDecodeError);
447
+ });
448
+ });
313
449
  }
@@ -1,50 +0,0 @@
1
- import { isCorrectEnvironment as isCorrectEnvironment$1, getClientSync as getClientSync$1 } from '@parity/truapi/sandbox';
2
-
3
- // src/transport.ts
4
- var clientOverride = null;
5
- function isProductionBuild() {
6
- try {
7
- return process.env.NODE_ENV === "production";
8
- } catch {
9
- return false;
10
- }
11
- }
12
- function setTruApiClient(client) {
13
- if (client !== null && isProductionBuild()) {
14
- console.warn(
15
- "[product-sdk] setTruApiClient() was called in a production build. This is a test-only seam from @parity/product-sdk-host/testing; a leaked import will silently reroute all host access to the injected client."
16
- );
17
- }
18
- clientOverride = client;
19
- }
20
- function getClientSync() {
21
- return clientOverride ?? getClientSync$1();
22
- }
23
- function isCorrectEnvironment() {
24
- return clientOverride !== null || isCorrectEnvironment$1();
25
- }
26
- async function getClient() {
27
- return getClientSync();
28
- }
29
- function subscribeWithInterrupt(observable, onNext) {
30
- let interruptCallback;
31
- const sub = observable.subscribe({
32
- next: onNext,
33
- error: (reason) => interruptCallback?.(reason),
34
- complete: () => interruptCallback?.()
35
- });
36
- return {
37
- subscriptionId: sub.subscriptionId,
38
- unsubscribe: () => sub.unsubscribe(),
39
- onInterrupt: (callback) => {
40
- interruptCallback = callback;
41
- return () => {
42
- if (interruptCallback === callback) interruptCallback = void 0;
43
- };
44
- }
45
- };
46
- }
47
-
48
- export { getClient, isCorrectEnvironment, setTruApiClient, subscribeWithInterrupt };
49
- //# sourceMappingURL=chunk-GDXSV7JV.js.map
50
- //# sourceMappingURL=chunk-GDXSV7JV.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/transport.ts"],"names":["sandboxGetClientSync","sandboxIsCorrectEnvironment"],"mappings":";;;AA8BA,IAAI,cAAA,GAAsC,IAAA;AAE1C,SAAS,iBAAA,GAA6B;AAClC,EAAA,IAAI;AAIA,IAAA,OAAO,OAAA,CAAQ,IAAI,QAAA,KAAa,YAAA;AAAA,EACpC,CAAA,CAAA,MAAQ;AAEJ,IAAA,OAAO,KAAA;AAAA,EACX;AACJ;AAYO,SAAS,gBAAgB,MAAA,EAAmC;AAC/D,EAAA,IAAI,MAAA,KAAW,IAAA,IAAQ,iBAAA,EAAkB,EAAG;AACxC,IAAA,OAAA,CAAQ,IAAA;AAAA,MACJ;AAAA,KACJ;AAAA,EACJ;AACA,EAAA,cAAA,GAAiB,MAAA;AACrB;AAMO,SAAS,aAAA,GAAqC;AACjD,EAAA,OAAO,kBAAkBA,eAAA,EAAqB;AAClD;AAMO,SAAS,oBAAA,GAAgC;AAC5C,EAAA,OAAO,cAAA,KAAmB,QAAQC,sBAAA,EAA4B;AAClE;AAMA,eAAsB,SAAA,GAA0C;AAC5D,EAAA,OAAO,aAAA,EAAc;AACzB;AAUO,SAAS,sBAAA,CACZ,YACA,MAAA,EACqB;AACrB,EAAA,IAAI,iBAAA;AACJ,EAAA,MAAM,GAAA,GAAM,WAAW,SAAA,CAAU;AAAA,IAC7B,IAAA,EAAM,MAAA;AAAA,IACN,KAAA,EAAO,CAAC,MAAA,KAAW,iBAAA,GAAoB,MAAM,CAAA;AAAA,IAC7C,QAAA,EAAU,MAAM,iBAAA;AAAoB,GACvC,CAAA;AACD,EAAA,OAAO;AAAA,IACH,gBAAgB,GAAA,CAAI,cAAA;AAAA,IACpB,WAAA,EAAa,MAAM,GAAA,CAAI,WAAA,EAAY;AAAA,IACnC,WAAA,EAAa,CAAC,QAAA,KAAa;AACvB,MAAA,iBAAA,GAAoB,QAAA;AACpB,MAAA,OAAO,MAAM;AACT,QAAA,IAAI,iBAAA,KAAsB,UAAU,iBAAA,GAAoB,MAAA;AAAA,MAC5D,CAAA;AAAA,IACJ;AAAA,GACJ;AACJ","file":"chunk-GDXSV7JV.js","sourcesContent":["// Copyright 2026 Parity Technologies (UK) Ltd.\n// SPDX-License-Identifier: Apache-2.0\n/**\n * Access to the in-house TruAPI client (`@parity/truapi`) for the host package.\n *\n * Environment detection and the lazily-built, cached client come from\n * `@parity/truapi/sandbox`; this module layers the product-sdk-specific glue on\n * top — an async {@link getClient} accessor and {@link subscribeWithInterrupt},\n * which adapts a truapi stream into the host's {@link HostSubscription} shape.\n *\n * @module\n */\n\nimport type { ObservableLike, TrUApiClient } from \"@parity/truapi\";\nimport {\n getClientSync as sandboxGetClientSync,\n isCorrectEnvironment as sandboxIsCorrectEnvironment,\n} from \"@parity/truapi/sandbox\";\n\nimport type { HostSubscription } from \"./types.js\";\n\n/** A {@link HostSubscription} carrying the transport-assigned subscription id. */\nexport interface TransportSubscription extends HostSubscription {\n readonly subscriptionId: string;\n}\n\n// Test-only override. When set — via `setTruApiClient`, exposed through\n// `@parity/product-sdk-host/testing` — every host accessor resolves this client\n// instead of the sandbox one. `null` in production, so the branches below are\n// no-ops there.\nlet clientOverride: TrUApiClient | null = null;\n\nfunction isProductionBuild(): boolean {\n try {\n // Must stay a plain `process.env.NODE_ENV` member expression: bundlers\n // (Vite, esbuild, webpack) substitute it textually, which is how this\n // check works in browser builds where `process` doesn't exist.\n return process.env.NODE_ENV === \"production\";\n } catch {\n // No `process` and no bundler define — can't tell, stay quiet.\n return false;\n }\n}\n\n/**\n * Test-only seam: force {@link getClient} / {@link getClientSync} to return\n * `client`, and {@link isCorrectEnvironment} to report `true`. Pass `null` to\n * restore normal detection. Exposed through `@parity/product-sdk-host/testing`,\n * not the package's main entry.\n *\n * Calling this in a production build silently reroutes every host accessor to\n * the injected client, so we warn — it almost always means a `/testing` import\n * leaked into a production path.\n */\nexport function setTruApiClient(client: TrUApiClient | null): void {\n if (client !== null && isProductionBuild()) {\n console.warn(\n \"[product-sdk] setTruApiClient() was called in a production build. This is a test-only seam from @parity/product-sdk-host/testing; a leaked import will silently reroute all host access to the injected client.\",\n );\n }\n clientOverride = client;\n}\n\n/**\n * Synchronous TruAPI client accessor. Returns the injected test client when one\n * is set, otherwise the sandbox client (`null` outside a host container).\n */\nexport function getClientSync(): TrUApiClient | null {\n return clientOverride ?? sandboxGetClientSync();\n}\n\n/**\n * Host-container detection. `true` when a test client is injected, otherwise the\n * sandbox heuristic (iframe / webview marker / injected message port).\n */\nexport function isCorrectEnvironment(): boolean {\n return clientOverride !== null || sandboxIsCorrectEnvironment();\n}\n\n/**\n * Get the TruAPI client. Returns `null` outside a host container. Async wrapper\n * over {@link getClientSync} for the host wrappers that already `await` it.\n */\nexport async function getClient(): Promise<TrUApiClient | null> {\n return getClientSync();\n}\n\n/**\n * Adapt a truapi `ObservableLike` stream into the host's callback-style\n * {@link HostSubscription} (`unsubscribe` + `onInterrupt`). `onNext` fires for\n * each item; the registered `onInterrupt` callback fires when the host ends the\n * subscription server-side — which the generated client surfaces as either\n * `complete` (a host interrupt frame) or `error` (transport close). Shared by\n * the statement-store and preimage adapters, which both expose this shape.\n */\nexport function subscribeWithInterrupt<Item, Reason = never>(\n observable: ObservableLike<Item, Reason>,\n onNext: (item: Item) => void,\n): TransportSubscription {\n let interruptCallback: ((reason?: unknown) => void) | undefined;\n const sub = observable.subscribe({\n next: onNext,\n error: (reason) => interruptCallback?.(reason),\n complete: () => interruptCallback?.(),\n });\n return {\n subscriptionId: sub.subscriptionId,\n unsubscribe: () => sub.unsubscribe(),\n onInterrupt: (callback) => {\n interruptCallback = callback;\n return () => {\n if (interruptCallback === callback) interruptCallback = undefined;\n };\n },\n };\n}\n\nif (import.meta.vitest) {\n const { test, expect, afterEach } = import.meta.vitest;\n\n afterEach(() => setTruApiClient(null));\n\n // Environment detection and client building are covered by `@parity/truapi`'s\n // own sandbox tests; here we only assert the local glue degrades outside a\n // host container.\n test(\"getClientSync returns null outside a container\", () => {\n expect(getClientSync()).toBeNull();\n });\n\n test(\"getClient resolves null outside a container\", async () => {\n expect(await getClient()).toBeNull();\n });\n\n test(\"setTruApiClient overrides the client and container detection\", async () => {\n const fake = {} as TrUApiClient;\n setTruApiClient(fake);\n expect(getClientSync()).toBe(fake);\n expect(await getClient()).toBe(fake);\n expect(isCorrectEnvironment()).toBe(true);\n\n setTruApiClient(null);\n expect(getClientSync()).toBeNull();\n expect(isCorrectEnvironment()).toBe(false);\n });\n\n test(\"setTruApiClient warns when injecting in a production build\", () => {\n const original = process.env.NODE_ENV;\n const warnings: string[] = [];\n const realWarn = console.warn;\n console.warn = (...args: unknown[]) => void warnings.push(String(args[0]));\n try {\n process.env.NODE_ENV = \"production\";\n setTruApiClient({} as TrUApiClient);\n expect(warnings).toHaveLength(1);\n expect(warnings[0]).toContain(\"production build\");\n\n // Clearing the override must not warn.\n setTruApiClient(null);\n expect(warnings).toHaveLength(1);\n } finally {\n console.warn = realWarn;\n process.env.NODE_ENV = original;\n }\n });\n\n test(\"subscribeWithInterrupt preserves the transport subscription id\", () => {\n const observable = {\n subscribe: () => ({\n subscriptionId: \"p:17\",\n unsubscribe: () => {},\n }),\n [Symbol.observable]() {\n return this;\n },\n } as ObservableLike<never>;\n\n const subscription = subscribeWithInterrupt(observable, () => {});\n\n expect(subscription.subscriptionId).toBe(\"p:17\");\n });\n}\n"]}