@parity/product-sdk-host 0.17.0 → 0.19.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/locale.ts ADDED
@@ -0,0 +1,76 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Higher-level wrapper for the host's locale subscription, backed by
5
+ * `truApi.locale.subscribe`.
6
+ *
7
+ * `getLocaleProvider` returns a handle whose `subscribeLocale(cb)` delivers a
8
+ * typed {@link LocaleInfo} — a `{ languageTag }` struct carrying a BCP 47 tag
9
+ * such as `"en"`, `"pt-BR"` or `"zh-Hans"` — and yields a
10
+ * {@link HostSubscription} (`unsubscribe` + `onInterrupt`).
11
+ *
12
+ * @module
13
+ */
14
+
15
+ import type { HostLocaleSubscribeItem, TrUApiClient } from "@parity/truapi";
16
+
17
+ import { getClient, subscribeWithInterrupt } from "./transport.js";
18
+ import type { HostSubscription } from "./types.js";
19
+
20
+ /**
21
+ * Host locale value. A `{ languageTag }` struct re-exported from
22
+ * `@parity/truapi`.
23
+ */
24
+ export type LocaleInfo = HostLocaleSubscribeItem;
25
+
26
+ /**
27
+ * Host locale provider handle. `subscribeLocale(callback)` receives a typed
28
+ * {@link LocaleInfo} on every change and returns a {@link HostSubscription}.
29
+ */
30
+ export interface LocaleProvider {
31
+ subscribeLocale(callback: (locale: LocaleInfo) => void): HostSubscription;
32
+ }
33
+
34
+ /** Build a {@link LocaleProvider} over a TruAPI client's `locale` domain. */
35
+ function adaptLocaleProvider(client: TrUApiClient): LocaleProvider {
36
+ return {
37
+ subscribeLocale(callback) {
38
+ return subscribeWithInterrupt(client.locale.subscribe(), callback);
39
+ },
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Get the host locale provider, backed by `truApi.locale.*`. Returns `null`
45
+ * when running outside a host container.
46
+ *
47
+ * The tag is whatever the host reports; a product that ships no catalog entry
48
+ * for it chooses its own fallback.
49
+ *
50
+ * @returns The locale provider, or `null` if unavailable.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * import { getLocaleProvider } from "@parity/product-sdk-host";
55
+ *
56
+ * const provider = await getLocaleProvider();
57
+ * if (provider) {
58
+ * const sub = provider.subscribeLocale((locale) => {
59
+ * i18n.activate(SUPPORTED.has(locale.languageTag) ? locale.languageTag : "en");
60
+ * });
61
+ * // sub.unsubscribe() to stop listening
62
+ * }
63
+ * ```
64
+ */
65
+ export async function getLocaleProvider(): Promise<LocaleProvider | null> {
66
+ const client = await getClient();
67
+ return client ? adaptLocaleProvider(client) : null;
68
+ }
69
+
70
+ if (import.meta.vitest) {
71
+ const { test, expect } = import.meta.vitest;
72
+
73
+ test("getLocaleProvider returns null outside a container", async () => {
74
+ expect(await getLocaleProvider()).toBeNull();
75
+ });
76
+ }
package/src/testing.ts CHANGED
@@ -14,8 +14,9 @@
14
14
  * (and `getChainAPI()` on top of it) resolves in tests; see the `chainInfo`
15
15
  * option. Not modeled: the rest of the PAPI `chain` JSON-RPC surface behind
16
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
17
+ * selection — the `system` domain's `info` / `getProductContext`, and the
18
+ * `chat` / `coinPayment` / `entropy` / `locale` / `notifications` / `payment` /
19
+ * `permissions` / `resourceAllocation` / `theme` domains. Touching
19
20
  * an unmodeled domain throws a descriptive error rather than failing with
20
21
  * `undefined is not a function`.
21
22
  *
@@ -249,11 +250,12 @@ export function createFakeTruApiClient(options?: CreateFakeTruApiClientOptions):
249
250
  okAsync({ proof: { tag: "Sr25519", value: { signature, signer: publicKey } } }),
250
251
  submit: () => okAsync(undefined),
251
252
  },
252
- system: {
253
+ // `info` and `getProductContext` are not modeled; they still throw.
254
+ system: notModeled("system", {
253
255
  handshake: () => okAsync(undefined),
254
256
  featureSupported: () => okAsync({ supported: chainSupported }),
255
257
  navigateTo: () => okAsync(undefined),
256
- },
258
+ }),
257
259
  preimage: {
258
260
  lookupSubscribe: ({ request: { key } }) =>
259
261
  oneShotObservable({ value: preimages.get(key) }),
@@ -280,6 +282,7 @@ export function createFakeTruApiClient(options?: CreateFakeTruApiClientOptions):
280
282
  chat: notModeled("chat"),
281
283
  coinPayment: notModeled("coinPayment"),
282
284
  entropy: notModeled("entropy"),
285
+ locale: notModeled("locale"),
283
286
  notifications: notModeled("notifications"),
284
287
  payment: notModeled("payment"),
285
288
  permissions: notModeled("permissions"),
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
  }