@parity/product-sdk-host 0.17.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/index.ts CHANGED
@@ -70,6 +70,7 @@ export {
70
70
  HostError,
71
71
  HostUnavailableError,
72
72
  HostCallFailedError,
73
+ HostResponseDecodeError,
73
74
  isHostError,
74
75
  formatHostError,
75
76
  } from "./errors.js";
@@ -79,6 +80,7 @@ export type { HostErrorPayload } from "./errors.js";
79
80
  export { getAccountsProvider, findRingVrfKeyHandle } from "./accounts.js";
80
81
  export type {
81
82
  AccountsProvider,
83
+ WithDecodeError,
82
84
  DerivationIndex,
83
85
  HostAccount,
84
86
  ProductAccount,
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
  }