@parity/product-sdk-host 0.13.0 → 0.14.1
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/{chunk-3SWF5CWC.js → chunk-GDXSV7JV.js} +3 -2
- package/dist/chunk-GDXSV7JV.js.map +1 -0
- package/dist/index.d.ts +63 -47
- package/dist/index.js +59 -29
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +4 -3
- package/dist/testing.js +12 -3
- package/dist/testing.js.map +1 -1
- package/package.json +4 -3
- package/src/accounts.ts +101 -37
- package/src/chains.ts +3 -9
- package/src/errors.ts +77 -41
- package/src/index.ts +2 -0
- package/src/papi-provider.ts +94 -26
- package/src/payments.ts +8 -7
- package/src/testing.ts +15 -4
- package/src/transport.ts +23 -1
- package/dist/chunk-3SWF5CWC.js.map +0 -1
package/src/papi-provider.ts
CHANGED
|
@@ -6,11 +6,9 @@
|
|
|
6
6
|
* This is a backport of `@novasamatech/host-api-wrapper`'s
|
|
7
7
|
* `createPapiProvider` (`dist/papiProvider.js`) into product-sdk, with the
|
|
8
8
|
* call layer swapped from the novasama `hostApi` to the
|
|
9
|
-
* `@parity/truapi` client. The JSON-RPC ↔ chainHead bridge
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* over from the upstream module; only the per-method transport calls and their
|
|
13
|
-
* error/response unwrapping are re-pointed at `truApi.chain.*`.
|
|
9
|
+
* `@parity/truapi` client. The JSON-RPC ↔ chainHead bridge handles request
|
|
10
|
+
* dispatch, `chainHead_v1_followEvent` notification synthesis, and operation /
|
|
11
|
+
* broadcast bookkeeping over the structured `truApi.chain.*` methods.
|
|
14
12
|
*
|
|
15
13
|
* **Why a bridge at all.** PAPI speaks the JSON-RPC `chainHead`/`chainSpec`/
|
|
16
14
|
* `transaction` API; the host exposes the same operations as structured,
|
|
@@ -51,8 +49,7 @@ import type {
|
|
|
51
49
|
import { createLogger } from "@parity/product-sdk-logger";
|
|
52
50
|
|
|
53
51
|
import { formatHostError } from "./errors.js";
|
|
54
|
-
import { subscribeWithInterrupt } from "./transport.js";
|
|
55
|
-
import type { HostSubscription } from "./types.js";
|
|
52
|
+
import { subscribeWithInterrupt, type TransportSubscription } from "./transport.js";
|
|
56
53
|
|
|
57
54
|
const log = createLogger("host:papi");
|
|
58
55
|
|
|
@@ -195,11 +192,8 @@ export function createHostPapiProvider(
|
|
|
195
192
|
const chain = client.chain;
|
|
196
193
|
|
|
197
194
|
return (onMessage: (message: JsonRpcMessage) => void): JsonRpcConnection => {
|
|
198
|
-
const activeFollows = new Map<string,
|
|
195
|
+
const activeFollows = new Map<string, TransportSubscription>();
|
|
199
196
|
const activeBroadcasts = new Set<string>();
|
|
200
|
-
let nextSubId = 0;
|
|
201
|
-
|
|
202
|
-
const getNextSubId = () => `follow_${nextSubId++}`;
|
|
203
197
|
|
|
204
198
|
function sendJsonRpcResponse(id: JsonRpcRequest["id"], result: unknown): void {
|
|
205
199
|
onMessage({ jsonrpc: "2.0", id, result } as JsonRpcMessage);
|
|
@@ -227,31 +221,55 @@ export function createHostPapiProvider(
|
|
|
227
221
|
switch (method) {
|
|
228
222
|
case "chainHead_v1_follow": {
|
|
229
223
|
const [withRuntime] = params as [boolean];
|
|
230
|
-
const syntheticSubId = getNextSubId();
|
|
231
224
|
// The Stop branch unsubscribes its own host subscription, but the
|
|
232
225
|
// handle is this call's return value — the ref breaks that
|
|
233
226
|
// chicken-and-egg. (Releasing before forwarding the Stop is just
|
|
234
227
|
// cleanup; the consumer's synchronous refollow gets a fresh wire
|
|
235
228
|
// subscription either way.)
|
|
236
|
-
const ref: { handle?:
|
|
229
|
+
const ref: { handle?: TransportSubscription } = {};
|
|
230
|
+
const pendingItems: RemoteChainHeadFollowItem[] = [];
|
|
231
|
+
const forwardItem = (
|
|
232
|
+
followSubscriptionId: string,
|
|
233
|
+
item: RemoteChainHeadFollowItem,
|
|
234
|
+
) => {
|
|
235
|
+
if (item.tag === "Stop" && activeFollows.delete(followSubscriptionId)) {
|
|
236
|
+
ref.handle?.unsubscribe();
|
|
237
|
+
}
|
|
238
|
+
sendFollowEvent(followSubscriptionId, convertFollowEventToJsonRpc(item));
|
|
239
|
+
};
|
|
237
240
|
ref.handle = subscribeWithInterrupt(
|
|
238
241
|
chain.followHeadSubscribe({ request: { genesisHash, withRuntime } }),
|
|
239
242
|
(item) => {
|
|
240
|
-
|
|
241
|
-
|
|
243
|
+
const followSubscriptionId = ref.handle?.subscriptionId;
|
|
244
|
+
if (!followSubscriptionId) {
|
|
245
|
+
pendingItems.push(item);
|
|
246
|
+
return;
|
|
242
247
|
}
|
|
243
|
-
|
|
248
|
+
forwardItem(followSubscriptionId, item);
|
|
244
249
|
},
|
|
245
250
|
);
|
|
251
|
+
const followSubscriptionId = ref.handle.subscriptionId;
|
|
252
|
+
if (!followSubscriptionId) {
|
|
253
|
+
ref.handle.unsubscribe();
|
|
254
|
+
sendJsonRpcError(
|
|
255
|
+
id,
|
|
256
|
+
JSON_RPC_INTERNAL_ERROR,
|
|
257
|
+
"Host follow subscription did not start",
|
|
258
|
+
);
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
246
261
|
// A transport interrupt/close ends the stream without a Stop
|
|
247
262
|
// item; synthesize one so the consumer refollows.
|
|
248
263
|
ref.handle.onInterrupt(() => {
|
|
249
|
-
if (activeFollows.delete(
|
|
250
|
-
sendFollowEvent(
|
|
264
|
+
if (activeFollows.delete(followSubscriptionId)) {
|
|
265
|
+
sendFollowEvent(followSubscriptionId, { event: "stop" });
|
|
251
266
|
}
|
|
252
267
|
});
|
|
253
|
-
activeFollows.set(
|
|
254
|
-
sendJsonRpcResponse(id,
|
|
268
|
+
activeFollows.set(followSubscriptionId, ref.handle);
|
|
269
|
+
sendJsonRpcResponse(id, followSubscriptionId);
|
|
270
|
+
for (const item of pendingItems) {
|
|
271
|
+
forwardItem(followSubscriptionId, item);
|
|
272
|
+
}
|
|
255
273
|
break;
|
|
256
274
|
}
|
|
257
275
|
case "chainHead_v1_unfollow": {
|
|
@@ -475,11 +493,15 @@ if (import.meta.vitest) {
|
|
|
475
493
|
errors?: Record<string, unknown>;
|
|
476
494
|
/** Unsubscribe spy used by the follow subscription (defaults to a fresh `vi.fn()`). */
|
|
477
495
|
unsubscribe?: () => void;
|
|
496
|
+
/** Item emitted synchronously while the transport subscription starts. */
|
|
497
|
+
initialItem?: unknown;
|
|
478
498
|
captureObserver?: (observer: {
|
|
479
499
|
next: (i: unknown) => void;
|
|
480
500
|
error: (e: unknown) => void;
|
|
481
501
|
complete: () => void;
|
|
482
502
|
}) => void;
|
|
503
|
+
/** Transport request id assigned to the follow subscription. */
|
|
504
|
+
subscriptionId?: string;
|
|
483
505
|
}) {
|
|
484
506
|
const okMatch = (value: unknown) => ({
|
|
485
507
|
match: (ok: (v: unknown) => unknown, _err: (e: unknown) => unknown) => ok(value),
|
|
@@ -501,7 +523,13 @@ if (import.meta.vitest) {
|
|
|
501
523
|
complete: () => void;
|
|
502
524
|
}) => {
|
|
503
525
|
opts.captureObserver?.(observer);
|
|
504
|
-
|
|
526
|
+
if (opts.initialItem !== undefined) {
|
|
527
|
+
observer.next(opts.initialItem);
|
|
528
|
+
}
|
|
529
|
+
return {
|
|
530
|
+
subscriptionId: opts.subscriptionId ?? "p:41",
|
|
531
|
+
unsubscribe: opts.unsubscribe ?? vi.fn(),
|
|
532
|
+
};
|
|
505
533
|
},
|
|
506
534
|
[Symbol.observable as symbol]() {
|
|
507
535
|
return this;
|
|
@@ -530,11 +558,14 @@ if (import.meta.vitest) {
|
|
|
530
558
|
} as unknown as TrUApiClient;
|
|
531
559
|
}
|
|
532
560
|
|
|
533
|
-
test("follow
|
|
561
|
+
test("follow preserves the transport id for events and follow-up requests", () => {
|
|
534
562
|
let observer:
|
|
535
563
|
| { next: (i: unknown) => void; error: (e: unknown) => void; complete: () => void }
|
|
536
564
|
| undefined;
|
|
565
|
+
const calls: Array<[string, unknown]> = [];
|
|
537
566
|
const client = makeFakeClient({
|
|
567
|
+
subscriptionId: "p:17",
|
|
568
|
+
onCall: (method, args) => calls.push([method, args]),
|
|
538
569
|
captureObserver: (o) => {
|
|
539
570
|
observer = o;
|
|
540
571
|
},
|
|
@@ -545,8 +576,7 @@ if (import.meta.vitest) {
|
|
|
545
576
|
const conn = provider((m) => messages.push(m));
|
|
546
577
|
|
|
547
578
|
conn.send({ jsonrpc: "2.0", id: 1, method: "chainHead_v1_follow", params: [true] });
|
|
548
|
-
|
|
549
|
-
expect(messages[0]).toEqual({ jsonrpc: "2.0", id: 1, result: "follow_0" });
|
|
579
|
+
expect(messages[0]).toEqual({ jsonrpc: "2.0", id: 1, result: "p:17" });
|
|
550
580
|
|
|
551
581
|
// A typed BestBlockChanged item becomes a chainHead_v1_followEvent.
|
|
552
582
|
observer?.next({ tag: "BestBlockChanged", value: { bestBlockHash: "0xbeef" } });
|
|
@@ -554,10 +584,48 @@ if (import.meta.vitest) {
|
|
|
554
584
|
jsonrpc: "2.0",
|
|
555
585
|
method: "chainHead_v1_followEvent",
|
|
556
586
|
params: {
|
|
557
|
-
subscription: "
|
|
587
|
+
subscription: "p:17",
|
|
558
588
|
result: { event: "bestBlockChanged", bestBlockHash: "0xbeef" },
|
|
559
589
|
},
|
|
560
590
|
});
|
|
591
|
+
|
|
592
|
+
conn.send({
|
|
593
|
+
jsonrpc: "2.0",
|
|
594
|
+
id: 2,
|
|
595
|
+
method: "chainHead_v1_header",
|
|
596
|
+
params: ["p:17", "0xbeef"],
|
|
597
|
+
});
|
|
598
|
+
expect(calls).toContainEqual([
|
|
599
|
+
"getHeadHeader",
|
|
600
|
+
{
|
|
601
|
+
genesisHash: "0xfeed",
|
|
602
|
+
followSubscriptionId: "p:17",
|
|
603
|
+
hash: "0xbeef",
|
|
604
|
+
},
|
|
605
|
+
]);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("follow buffers an item emitted while the transport subscription starts", () => {
|
|
609
|
+
const client = makeFakeClient({
|
|
610
|
+
subscriptionId: "p:18",
|
|
611
|
+
initialItem: { tag: "BestBlockChanged", value: { bestBlockHash: "0xbeef" } },
|
|
612
|
+
});
|
|
613
|
+
const messages: JsonRpcMessage[] = [];
|
|
614
|
+
const conn = createHostPapiProvider(client, "0xfeed")((message) => messages.push(message));
|
|
615
|
+
|
|
616
|
+
conn.send({ jsonrpc: "2.0", id: 1, method: "chainHead_v1_follow", params: [true] });
|
|
617
|
+
|
|
618
|
+
expect(messages).toEqual([
|
|
619
|
+
{ jsonrpc: "2.0", id: 1, result: "p:18" },
|
|
620
|
+
{
|
|
621
|
+
jsonrpc: "2.0",
|
|
622
|
+
method: "chainHead_v1_followEvent",
|
|
623
|
+
params: {
|
|
624
|
+
subscription: "p:18",
|
|
625
|
+
result: { event: "bestBlockChanged", bestBlockHash: "0xbeef" },
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
]);
|
|
561
629
|
});
|
|
562
630
|
|
|
563
631
|
test("chainSpec_v1_properties parses the JSON-encoded properties string", () => {
|
|
@@ -628,7 +696,7 @@ if (import.meta.vitest) {
|
|
|
628
696
|
expect(messages[1]).toEqual({
|
|
629
697
|
jsonrpc: "2.0",
|
|
630
698
|
method: "chainHead_v1_followEvent",
|
|
631
|
-
params: { subscription: "
|
|
699
|
+
params: { subscription: "p:41", result: { event: "stop" } },
|
|
632
700
|
});
|
|
633
701
|
});
|
|
634
702
|
|
package/src/payments.ts
CHANGED
|
@@ -5,16 +5,17 @@
|
|
|
5
5
|
* `truApi.payment.*`.
|
|
6
6
|
*
|
|
7
7
|
* Exposes balance subscription, top-up, payment requests, and payment-status
|
|
8
|
-
* subscription.
|
|
9
|
-
* (RFC-0017)
|
|
10
|
-
* flow
|
|
8
|
+
* subscription. The flow is distinct from the CoinPayment / merchant-payments
|
|
9
|
+
* surface (RFC-0017) — RFC-0006 is the user-initiated balance / top-up /
|
|
10
|
+
* payment-request flow — but both operate on the same CoinPayment purses,
|
|
11
|
+
* hence the shared `CoinPaymentPurseId` (omitted = the main purse).
|
|
11
12
|
*
|
|
12
13
|
* @module
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import type {
|
|
16
17
|
Balance,
|
|
17
|
-
|
|
18
|
+
CoinPaymentPurseId,
|
|
18
19
|
HexString,
|
|
19
20
|
HostPaymentBalanceSubscribeItem,
|
|
20
21
|
HostPaymentStatusSubscribeItem,
|
|
@@ -37,13 +38,13 @@ import type { HostSubscription } from "./types.js";
|
|
|
37
38
|
export interface PaymentManager {
|
|
38
39
|
subscribeBalance(
|
|
39
40
|
callback: (balance: HostPaymentBalanceSubscribeItem) => void,
|
|
40
|
-
purse?:
|
|
41
|
+
purse?: CoinPaymentPurseId,
|
|
41
42
|
): HostSubscription;
|
|
42
|
-
topUp(amount: Balance, source: PaymentTopUpSource, into?:
|
|
43
|
+
topUp(amount: Balance, source: PaymentTopUpSource, into?: CoinPaymentPurseId): Promise<void>;
|
|
43
44
|
requestPayment(
|
|
44
45
|
amount: Balance,
|
|
45
46
|
destination: HexString,
|
|
46
|
-
from?:
|
|
47
|
+
from?: CoinPaymentPurseId,
|
|
47
48
|
): Promise<{ id: string }>;
|
|
48
49
|
subscribePaymentStatus(
|
|
49
50
|
paymentId: string,
|
package/src/testing.ts
CHANGED
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Not modeled: the PAPI `chain` JSON-RPC surface behind `getHostProvider()` —
|
|
14
14
|
* there's no chain-read fake, by design; the host owns RPC selection — and the
|
|
15
|
-
* `chat` / `
|
|
16
|
-
* `resourceAllocation` / `theme` domains. Touching an unmodeled
|
|
17
|
-
* descriptive error rather than failing with `undefined is not
|
|
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`.
|
|
18
19
|
*
|
|
19
20
|
* @packageDocumentation
|
|
20
21
|
*/
|
|
@@ -182,7 +183,16 @@ export function createFakeTruApiClient(options?: CreateFakeTruApiClientOptions):
|
|
|
182
183
|
getAccountAlias: () =>
|
|
183
184
|
okAsync({ context: toHex(new Uint8Array([1])), alias: toHex(new Uint8Array([2])) }),
|
|
184
185
|
getLegacyAccounts: () => okAsync({ accounts: legacyAccounts }),
|
|
185
|
-
createAccountProof: () =>
|
|
186
|
+
createAccountProof: () =>
|
|
187
|
+
okAsync({
|
|
188
|
+
proof: signature,
|
|
189
|
+
contextualAlias: {
|
|
190
|
+
context: toHex(new Uint8Array([1])),
|
|
191
|
+
alias: toHex(new Uint8Array([2])),
|
|
192
|
+
},
|
|
193
|
+
ringIndex: 0,
|
|
194
|
+
ringRevision: 0,
|
|
195
|
+
}),
|
|
186
196
|
connectionStatusSubscribe: () => inertObservable(),
|
|
187
197
|
},
|
|
188
198
|
signing: {
|
|
@@ -217,6 +227,7 @@ export function createFakeTruApiClient(options?: CreateFakeTruApiClientOptions):
|
|
|
217
227
|
},
|
|
218
228
|
chain: notModeled("chain"),
|
|
219
229
|
chat: notModeled("chat"),
|
|
230
|
+
coinPayment: notModeled("coinPayment"),
|
|
220
231
|
entropy: notModeled("entropy"),
|
|
221
232
|
notifications: notModeled("notifications"),
|
|
222
233
|
payment: notModeled("payment"),
|
package/src/transport.ts
CHANGED
|
@@ -19,6 +19,11 @@ import {
|
|
|
19
19
|
|
|
20
20
|
import type { HostSubscription } from "./types.js";
|
|
21
21
|
|
|
22
|
+
/** A {@link HostSubscription} carrying the transport-assigned subscription id. */
|
|
23
|
+
export interface TransportSubscription extends HostSubscription {
|
|
24
|
+
readonly subscriptionId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
22
27
|
// Test-only override. When set — via `setTruApiClient`, exposed through
|
|
23
28
|
// `@parity/product-sdk-host/testing` — every host accessor resolves this client
|
|
24
29
|
// instead of the sandbox one. `null` in production, so the branches below are
|
|
@@ -91,7 +96,7 @@ export async function getClient(): Promise<TrUApiClient | null> {
|
|
|
91
96
|
export function subscribeWithInterrupt<Item, Reason = never>(
|
|
92
97
|
observable: ObservableLike<Item, Reason>,
|
|
93
98
|
onNext: (item: Item) => void,
|
|
94
|
-
):
|
|
99
|
+
): TransportSubscription {
|
|
95
100
|
let interruptCallback: ((reason?: unknown) => void) | undefined;
|
|
96
101
|
const sub = observable.subscribe({
|
|
97
102
|
next: onNext,
|
|
@@ -99,6 +104,7 @@ export function subscribeWithInterrupt<Item, Reason = never>(
|
|
|
99
104
|
complete: () => interruptCallback?.(),
|
|
100
105
|
});
|
|
101
106
|
return {
|
|
107
|
+
subscriptionId: sub.subscriptionId,
|
|
102
108
|
unsubscribe: () => sub.unsubscribe(),
|
|
103
109
|
onInterrupt: (callback) => {
|
|
104
110
|
interruptCallback = callback;
|
|
@@ -156,4 +162,20 @@ if (import.meta.vitest) {
|
|
|
156
162
|
process.env.NODE_ENV = original;
|
|
157
163
|
}
|
|
158
164
|
});
|
|
165
|
+
|
|
166
|
+
test("subscribeWithInterrupt preserves the transport subscription id", () => {
|
|
167
|
+
const observable = {
|
|
168
|
+
subscribe: () => ({
|
|
169
|
+
subscriptionId: "p:17",
|
|
170
|
+
unsubscribe: () => {},
|
|
171
|
+
}),
|
|
172
|
+
[Symbol.observable]() {
|
|
173
|
+
return this;
|
|
174
|
+
},
|
|
175
|
+
} as ObservableLike<never>;
|
|
176
|
+
|
|
177
|
+
const subscription = subscribeWithInterrupt(observable, () => {});
|
|
178
|
+
|
|
179
|
+
expect(subscription.subscriptionId).toBe("p:17");
|
|
180
|
+
});
|
|
159
181
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/transport.ts"],"names":["sandboxGetClientSync","sandboxIsCorrectEnvironment"],"mappings":";;;AAyBA,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,EACgB;AAChB,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,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-3SWF5CWC.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// 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): HostSubscription {\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 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"]}
|