@parity/product-sdk-host 0.15.1 → 0.17.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/testing.ts CHANGED
@@ -10,21 +10,25 @@
10
10
  * makes a default `SignerManager`, `local-storage` auto-detection, and the
11
11
  * `statement-store` / `cloud-storage` host paths testable.
12
12
  *
13
- * Not modeled: the PAPI `chain` JSON-RPC surface behind `getHostProvider()`
14
- * there's no chain-read fake, by design; the host owns RPC selection — and the
15
- * `chat` / `coinPayment` / `entropy` / `notifications` / `payment` /
16
- * `permissions` / `resourceAllocation` / `theme` domains. Touching an unmodeled
17
- * domain throws a descriptive error rather than failing with `undefined is not
18
- * a function`.
13
+ * Of the `chain` domain only `getChainInfo` is modeled, so host chain discovery
14
+ * (and `getChainAPI()` on top of it) resolves in tests; see the `chainInfo`
15
+ * option. Not modeled: the rest of the PAPI `chain` JSON-RPC surface behind
16
+ * `getHostProvider()` there's no chain-read fake, by design; the host owns RPC
17
+ * selection and the `chat` / `coinPayment` / `entropy` / `notifications` /
18
+ * `payment` / `permissions` / `resourceAllocation` / `theme` domains. Touching
19
+ * an unmodeled domain throws a descriptive error rather than failing with
20
+ * `undefined is not a function`.
19
21
  *
20
22
  * @packageDocumentation
21
23
  */
22
24
  import type { ObservableLike, Observer, Subscription, TrUApiClient } from "@parity/truapi";
23
- import { okAsync } from "neverthrow";
25
+ import { errAsync, okAsync } from "neverthrow";
24
26
 
25
- import { setTruApiClient } from "./transport.js";
27
+ import type { HostChainIdentifier } from "./chain-discovery.js";
26
28
 
27
- export { setTruApiClient };
29
+ import { type HostConnectionStatus, emitConnectionStatus, setTruApiClient } from "./transport.js";
30
+
31
+ export { setTruApiClient, emitConnectionStatus };
28
32
 
29
33
  /**
30
34
  * The public surface of a generated truapi domain client. `keyof` skips private
@@ -91,10 +95,17 @@ function oneShotObservable<Item>(item: Item): ObservableLike<Item> {
91
95
  * member access throws with a pointer here, instead of the bare TypeError an
92
96
  * empty stub would give. The empty-object cast is the one concession a Proxy
93
97
  * needs; every modeled domain is checked structurally.
98
+ *
99
+ * `modeled` carries the members that _are_ implemented, for a domain where the
100
+ * fake covers some calls but not the whole surface.
94
101
  */
95
- function notModeled<D extends keyof TrUApiClient>(domain: D): PublicSurface<TrUApiClient[D]> {
96
- return new Proxy({} as PublicSurface<TrUApiClient[D]>, {
97
- get(_target, member) {
102
+ function notModeled<D extends keyof TrUApiClient>(
103
+ domain: D,
104
+ modeled?: Partial<PublicSurface<TrUApiClient[D]>>,
105
+ ): PublicSurface<TrUApiClient[D]> {
106
+ return new Proxy((modeled ?? {}) as PublicSurface<TrUApiClient[D]>, {
107
+ get(target, member) {
108
+ if (member in target) return target[member as keyof typeof target];
98
109
  // Stay quiet for inspection probes (console.log, await-resolution).
99
110
  if (typeof member === "symbol" || member === "then") return undefined;
100
111
  throw new Error(
@@ -114,6 +125,22 @@ function preimageKey(hexValue: string): `0x${string}` {
114
125
  return `0x${(h >>> 0).toString(16).padStart(8, "0")}`;
115
126
  }
116
127
 
128
+ /**
129
+ * What a fake host reports through `chain.getChainInfo`: the network id it is
130
+ * configured for, and a genesis hash per chain role it serves.
131
+ *
132
+ * Roles left out are answered `NotSupported`, exactly as a host that does not
133
+ * serve them. Use the genesis hashes the descriptors expose (e.g.
134
+ * `paseo_asset_hub.genesis`) if the test drives `getChainAPI()`, since the
135
+ * environment is derived by matching the asset hub genesis against the bundle.
136
+ */
137
+ export interface FakeChainInfo {
138
+ /** Ecosystem the fake host claims, e.g. `"paseo"`. */
139
+ network: string;
140
+ /** Genesis hash per chain role served. */
141
+ chains: Partial<Record<HostChainIdentifier, `0x${string}`>>;
142
+ }
143
+
117
144
  /** Options for {@link createFakeTruApiClient}. */
118
145
  export interface CreateFakeTruApiClientOptions {
119
146
  /** `account.getUserId` primary username. Default `"alice.dot"`. */
@@ -130,6 +157,12 @@ export interface CreateFakeTruApiClientOptions {
130
157
  legacyAccounts?: Array<{ publicKey: Uint8Array; name: string }>;
131
158
  /** Seed the in-memory preimage store, keyed by the `0x` preimage key. */
132
159
  preimages?: Record<string, Uint8Array>;
160
+ /**
161
+ * What `chain.getChainInfo` reports. Omit to model a host predating chain
162
+ * discovery: the call is refused as `Unsupported`, so `getHostChainInfo`
163
+ * resolves `null` and `getChainAPI` needs an explicit environment.
164
+ */
165
+ chainInfo?: FakeChainInfo;
133
166
  }
134
167
 
135
168
  /**
@@ -143,6 +176,7 @@ export function createFakeTruApiClient(options?: CreateFakeTruApiClientOptions):
143
176
  const publicKey = toHex(options?.publicKey ?? new Uint8Array(32).fill(0x11));
144
177
  const signature = toHex(options?.signature ?? new Uint8Array(64).fill(0x22));
145
178
  const chainSupported = options?.chainSupported ?? true;
179
+ const chainInfo = options?.chainInfo;
146
180
 
147
181
  // Real in-memory KV (hex values) so getHostLocalStorage()/createLocalKvStore() round-trip.
148
182
  const kv = new Map<string, `0x${string}`>();
@@ -193,6 +227,9 @@ export function createFakeTruApiClient(options?: CreateFakeTruApiClientOptions):
193
227
  ringIndex: 0,
194
228
  ringRevision: 0,
195
229
  }),
230
+ registerRingVrfKey: () => okAsync(publicKey),
231
+ listRingVrfKeys: () => okAsync([]),
232
+ ringVrfSign: () => okAsync(signature),
196
233
  signVrf: () => okAsync({ preOutput: publicKey, proof: signature }),
197
234
  connectionStatusSubscribe: () => inertObservable(),
198
235
  },
@@ -226,7 +263,20 @@ export function createFakeTruApiClient(options?: CreateFakeTruApiClientOptions):
226
263
  return okAsync(key);
227
264
  },
228
265
  },
229
- chain: notModeled("chain"),
266
+ // Only `getChainInfo` is modeled; the rest of the domain still throws.
267
+ chain: notModeled("chain", {
268
+ getChainInfo: ({ chain }) => {
269
+ if (!chainInfo) return errAsync({ tag: "Unsupported" } as const);
270
+ const genesisHash = chainInfo.chains[chain];
271
+ if (!genesisHash) {
272
+ return errAsync({
273
+ tag: "Domain",
274
+ value: { tag: "V1", value: { tag: "NotSupported" } },
275
+ } as const);
276
+ }
277
+ return okAsync({ network: chainInfo.network, chain, genesisHash });
278
+ },
279
+ }),
230
280
  chat: notModeled("chat"),
231
281
  coinPayment: notModeled("coinPayment"),
232
282
  entropy: notModeled("entropy"),
@@ -254,6 +304,12 @@ type TestFinishedHook = (fn: () => void) => void;
254
304
  export interface FakeHost extends Disposable {
255
305
  /** The injected fake client (also what `getTruApi()` returns). */
256
306
  client: TrUApiClient;
307
+ /**
308
+ * Push a status to `subscribeConnectionStatus` subscribers, so a product's
309
+ * reconnecting / offline UI can be exercised. `dispose()` already reports
310
+ * `"disconnected"`; use this to drive the states in between.
311
+ */
312
+ emitConnectionStatus(status: HostConnectionStatus): void;
257
313
  /** Clear the override. Idempotent; safe to call more than once. */
258
314
  dispose(): void;
259
315
  /** `using host = createFakeHost()` restores the real client at scope end. */
@@ -305,6 +361,7 @@ export function createFakeHost(options?: CreateFakeTruApiClientOptions): FakeHos
305
361
 
306
362
  return {
307
363
  client,
364
+ emitConnectionStatus,
308
365
  dispose,
309
366
  [Symbol.dispose]: dispose,
310
367
  };
@@ -317,6 +374,7 @@ if (import.meta.vitest) {
317
374
  "./container.js"
318
375
  );
319
376
  const { getAccountsProvider } = await import("./accounts.js");
377
+ const { getHostChainInfo } = await import("./chain-discovery.js");
320
378
  const { getPreimageManager } = await import("./truapi.js");
321
379
 
322
380
  const lookupOnce = (
@@ -360,6 +418,50 @@ if (import.meta.vitest) {
360
418
  expect(userId?.primaryUsername).toBe("carol.dot");
361
419
  });
362
420
 
421
+ test("signVrf round-trips through the real adapter", async () => {
422
+ // The fake is the only way a product can test a VRF flow: there is no
423
+ // dev-provider implementation and the e2e test host does not expose the
424
+ // call. So the fake's wire shape has to stay decodable by the adapter.
425
+ createFakeHost();
426
+ const accounts = await getAccountsProvider();
427
+ const signature = await accounts
428
+ ?.signVrf({ dotNsIdentifier: "app.dot" }, new Uint8Array([1]), [
429
+ { label: new Uint8Array([2]), value: new Uint8Array([3]) },
430
+ ])
431
+ .match(
432
+ (s) => s,
433
+ () => null,
434
+ );
435
+ expect(signature?.preOutput).toBeInstanceOf(Uint8Array);
436
+ expect(signature?.proof).toBeInstanceOf(Uint8Array);
437
+ expect(signature?.preOutput).toHaveLength(32);
438
+ expect(signature?.proof).toHaveLength(64);
439
+ });
440
+
441
+ test("chain discovery is refused by default, so a legacy host is modeled", async () => {
442
+ createFakeHost();
443
+ // Refused, not unmodeled: a bare `getChainAPI("paseo")` in a consumer
444
+ // test must not warn about the fake on every call.
445
+ expect(await getHostChainInfo(["AssetHub"])).toBeNull();
446
+ });
447
+
448
+ test("chainInfo drives discovery, with unserved roles left out", async () => {
449
+ createFakeHost({
450
+ chainInfo: { network: "paseo", chains: { AssetHub: "0xaa", Bulletin: "0xbb" } },
451
+ });
452
+ expect(await getHostChainInfo(["AssetHub", "Bulletin", "People"])).toEqual({
453
+ network: "paseo",
454
+ chains: { AssetHub: "0xaa", Bulletin: "0xbb" },
455
+ });
456
+ });
457
+
458
+ test("the rest of the chain domain still reports itself unmodeled", async () => {
459
+ const host = createFakeHost();
460
+ expect(() => (host.client.chain as { chainName?: unknown }).chainName).toThrow(
461
+ /not modeled by the fake/,
462
+ );
463
+ });
464
+
363
465
  test("statement store resolves", async () => {
364
466
  createFakeHost();
365
467
  expect(await getStatementStore()).not.toBeNull();
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
@@ -183,8 +183,9 @@ export async function createHostPreimageManager(): Promise<PreimageManager | nul
183
183
  // Resource-allocation / permission types, re-exported verbatim from
184
184
  // `@parity/truapi` (imported above for the local signatures):
185
185
  // - `AllocatableResource` — resource types requestable via `requestResourceAllocation`.
186
- // Since truapi 0.6.0 its `SmartContractAllowance` variant carries the tagged
187
- // `DerivationIndex` selector (`{ tag: "Left", value: number }` for a plain index).
186
+ // Its `SmartContractAllowance` variant carries the tagged `DerivationIndex`
187
+ // selector (`{ tag: "Index", value: number }` for a plain index, `{ tag:
188
+ // "Raw", value: HexString }` for a raw 32-byte index).
188
189
  // - `AllocationOutcome` — per-resource outcome, the string union
189
190
  // `"Allocated" | "Rejected" | "NotAvailable"` (RFC-10).
190
191
  // - `RemotePermission` — permission the dapp asks the host to grant via `requestPermission`.
@@ -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"]}
@@ -1,31 +0,0 @@
1
- import { TrUApiClient } from '@parity/truapi';
2
-
3
- /**
4
- * Access to the in-house TruAPI client (`@parity/truapi`) for the host package.
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.
10
- *
11
- * @module
12
- */
13
-
14
- /**
15
- * Test-only seam: force {@link getClient} / {@link getClientSync} to return
16
- * `client`, and {@link isCorrectEnvironment} to report `true`. Pass `null` to
17
- * restore normal detection. Exposed through `@parity/product-sdk-host/testing`,
18
- * not the package's main entry.
19
- *
20
- * Calling this in a production build silently reroutes every host accessor to
21
- * the injected client, so we warn — it almost always means a `/testing` import
22
- * leaked into a production path.
23
- */
24
- declare function setTruApiClient(client: TrUApiClient | null): void;
25
- /**
26
- * Host-container detection. `true` when a test client is injected, otherwise the
27
- * sandbox heuristic (iframe / webview marker / injected message port).
28
- */
29
- declare function isCorrectEnvironment(): boolean;
30
-
31
- export { isCorrectEnvironment as i, setTruApiClient as s };