@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/dist/index.d.ts +94 -13
- package/dist/index.js +177 -69
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +3 -2
- package/dist/testing.js +4 -2
- package/dist/testing.js.map +1 -1
- package/package.json +4 -4
- package/src/accounts.ts +295 -107
- package/src/errors.ts +46 -0
- package/src/index.ts +6 -0
- package/src/locale.ts +76 -0
- package/src/testing.ts +7 -4
- package/src/truapi.ts +139 -3
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 —
|
|
18
|
-
* `
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|