commit 6083a83431ab755999a8c5e9e7d25c8fe3405160
parent bb9da11eb1070ea248b21e1d9026e00794797b57
Author: Sebastian <sebasjm@taler-systems.com>
Date: Tue, 14 Jul 2026 09:18:35 -0300
fix #11623 long polling issues
Diffstat:
8 files changed, 189 insertions(+), 236 deletions(-)
diff --git a/packages/libeufin-bank-webui/src/hooks/account.ts b/packages/libeufin-bank-webui/src/hooks/account.ts
@@ -29,6 +29,7 @@ import { useSessionState } from "./session.js";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
import { dummyHttpResponse } from "@gnu-taler/taler-util/http";
import {
+ LONG_POLL_DELAY,
PaginatedResult,
useAsync,
useBankCoreApiContext,
@@ -85,41 +86,33 @@ export function useWithdrawalDetails(wid: string | undefined) {
lib: { bank: api },
} = useBankCoreApiContext();
- //FIXME: use swr
- const prev = useAsync(
- wid === undefined
- ? undefined
- : () => {
- return api.getWithdrawalById(wid, undefined);
- },
- );
+ type Res = undefined | Awaited<ReturnType<typeof api.getWithdrawalById>>;
const result = useLongPolling(
- prev,
- (result) => {
- if (!result || result instanceof TalerError || result.type === "fail")
- return false;
- const { status } = result.body;
- return status === "pending" || status === "selected";
- },
- async (ct, r) => {
- if (
- !r ||
- r instanceof TalerError ||
- r.type === "fail" ||
- r.body.status === "confirmed" ||
- r.body.status === "aborted"
- ) {
- return undefined;
- }
+ async (ct, res): Promise<Res> => {
+ const old_state =
+ !res || res instanceof TalerError || res.type === "fail"
+ ? undefined
+ : res.body.status;
const result = await api.getWithdrawalById(wid!, {
- old_state: r.body.status,
- timeoutMs: 5000,
+ old_state,
+ timeoutMs: LONG_POLL_DELAY,
ct,
});
return result;
},
- [wid],
+ {
+ minTime: LONG_POLL_DELAY,
+ deps: [wid],
+ shouldStop(res) {
+ return (
+ res !== undefined &&
+ !(res instanceof TalerError) &&
+ res.type !== "fail" &&
+ (res.body.status === "confirmed" || res.body.status === "aborted")
+ );
+ },
+ },
);
return result;
diff --git a/packages/taler-exchange-kyc-webui/src/hooks/kyc.ts b/packages/taler-exchange-kyc-webui/src/hooks/kyc.ts
@@ -21,9 +21,9 @@ import {
} from "@gnu-taler/taler-util";
import { dummyHttpResponse } from "@gnu-taler/taler-util/http";
import {
- useAsync,
+ LONG_POLL_DELAY,
useExchangeApiContext,
- useLongPolling,
+ useLongPolling
} from "@gnu-taler/web-util/browser";
import _useSWR, { mutate, SWRHook } from "swr";
const useSWR = _useSWR as unknown as SWRHook;
@@ -41,36 +41,39 @@ export function useKycInfo(token?: AccessToken) {
lib: { exchange: api },
} = useExchangeApiContext();
- const prev = useAsync(
- token === undefined
- ? undefined
- : () => {
- return api.checkKycInfoSpa(token, undefined);
- },
- [token],
- );
+ type Res = undefined | Awaited<ReturnType<typeof api.checkKycInfoSpa>>;
const result = useLongPolling(
- prev,
- (result) => {
- if (!result || result instanceof TalerError || result.type === "fail")
- return false;
- if (!result.body.requirements.length) return false;
- return true;
- },
- async (ct, result) => {
- if (!result || result instanceof TalerError || result.type === "fail")
- return undefined;
- if (!result.body.requirements.length) return undefined;
-
- const res = await api.checkKycInfoSpa(token!, result.body.etag, {
- timeoutMs: 5000,
+ async (ct, result): Promise<Res> => {
+ const etag =
+ !result || result instanceof TalerError || result.type === "fail"
+ ? undefined
+ : result.body.etag;
+ const res = await api.checkKycInfoSpa(token!, etag, {
+ timeoutMs: LONG_POLL_DELAY,
});
if (res.type === "fail" && res.case === HttpStatusCode.NotModified) {
+ if (!result || result instanceof TalerError || result.type === "fail") {
+ // this should not happen, if we get at 304 is because
+ // we used a etag
+ throw Error("server replied 304 not modified but we dont have previous response.")
+ }
return opFixedSuccess(dummyHttpResponse, result.body);
}
return res;
},
- [token],
+ {
+ deps: [token],
+ shouldStop(result) {
+ // if error keep trying
+ // stop when no requirements left
+ return (
+ result !== undefined &&
+ !(result instanceof TalerError) &&
+ result.type !== "fail" &&
+ result.body.requirements.length === 0
+ );
+ },
+ },
);
return result;
diff --git a/packages/taler-merchant-webui/src/Application.tsx b/packages/taler-merchant-webui/src/Application.tsx
@@ -103,6 +103,7 @@ import {
fetchSettings,
} from "./settings.js";
import { SolveChallengeDialog } from "./components/SolveMFA.js";
+import { FullConfiguration } from "swr/_internal";
const TALER_SCREEN_ID = 2;
const WITH_LOCAL_STORAGE_CACHE = false;
@@ -158,7 +159,7 @@ function SubApp({ baseUrl }: { baseUrl: string }) {
// do not go to loading again if already has data
keepPreviousData: true,
- }}
+ } as Partial<FullConfiguration>}
>
<MerchantChallengeHandlerProvider>
<SessionContextProvider>
diff --git a/packages/taler-merchant-webui/src/context/currency.ts b/packages/taler-merchant-webui/src/context/currency.ts
@@ -23,10 +23,7 @@ import {
} from "@gnu-taler/taler-util";
import { ComponentChildren, createContext, h, VNode } from "preact";
import { useContext, useState } from "preact/hooks";
-import {
- useInstanceKYCDetails,
- useInstanceKYCDetailsLongPolling,
-} from "../hooks/instance.js";
+import { useInstanceKYCDetailsLongPolling } from "../hooks/instance.js";
import { useSessionContext } from "./session.js";
/**
@@ -38,7 +35,7 @@ export type Type = {
list: string[];
kycStatus: KycStatusMap;
currency: CurrencyWithId;
- kycResult: ReturnType<typeof useInstanceKYCDetails>;
+ kycResult: ReturnType<typeof useInstanceKYCDetailsLongPolling>;
switchCurrency: (id: string) => void;
};
@@ -144,44 +141,3 @@ export const CurrenciesProvider = ({
children,
});
};
-
-// export function useInstanceKYCSimplifiedWorstStatusLongPolling(
-// account?: Paytos.URI,
-// ) {
-// const kycStatus = useInstanceKYCDetailsLongPolling();
-
-// const allKycData =
-// kycStatus !== undefined &&
-// !(kycStatus instanceof TalerError) &&
-// kycStatus.type === "ok"
-// ? kycStatus.body.kyc_data
-// : [];
-
-// const uri_str =
-// account === undefined ? undefined : Paytos.toFullString(account);
-
-// const filtered =
-// uri_str === undefined
-// ? allKycData
-// : allKycData.filter((data) => data.payto_uri === uri_str);
-
-// const r = filtered.reduce((prev, cur) => {
-// const st = getMerchantAccountKycStatusSimplified(cur.status);
-// if (st > prev) return st;
-// return prev;
-// }, MerchantAccountKycStatusSimplified.OK);
-
-// return r;
-// }
-
-// export function useInstanceKYCSimplifiedBestStatusLongPolling() {
-// const kycStatus = useInstanceKYCDetailsLongPolling();
-
-// const allKycData =
-// kycStatus !== undefined &&
-// !(kycStatus instanceof TalerError) &&
-// kycStatus.type === "ok"
-// ? kycStatus.body.kyc_data
-// : [];
-
-// }
diff --git a/packages/taler-merchant-webui/src/hooks/instance.ts b/packages/taler-merchant-webui/src/hooks/instance.ts
@@ -16,10 +16,18 @@
import {
AccessToken,
+ isOperationFail,
+ MerchantAccountKycRedirectsResponse,
+ Result,
+ succeedOrThrow,
TalerHttpError,
TalerMerchantManagementResultByMethod,
} from "@gnu-taler/taler-util";
-import { LONG_POLL_DELAY, useLongPolling } from "@gnu-taler/web-util/browser";
+import {
+ delayMs,
+ LONG_POLL_DELAY,
+ useLongPolling,
+} from "@gnu-taler/web-util/browser";
import { useSessionContext } from "../context/session.js";
// Fix default import https://github.com/microsoft/TypeScript/issues/49189
@@ -66,65 +74,29 @@ export function useInstanceKYCDetailsLongPolling() {
const { state, lib } = useSessionContext();
const token = state.token;
- async function fetcher([token]: [AccessToken]) {
- return await lib.instance.getCurrentInstanceKycStatus(token);
- }
-
- const { data, error, mutate } = useSWR<
- TalerMerchantManagementResultByMethod<"getCurrentInstanceKycStatus">,
- TalerHttpError
- >([token, "getCurrentInstanceKycStatus"], fetcher);
-
+ type Res = Awaited<ReturnType<typeof lib.instance.getCurrentInstanceKycStatus>>;
+
const result = useLongPolling(
- data,
- (result) => {
- return true;
- },
- async (ct, latestData) => {
- if (
- latestData === undefined ||
- latestData.type === "fail" ||
- !latestData.body.etag
- )
- return undefined;
+ async (ct, latestData): Promise<Res> => {
+ const etag = latestData?.type === "ok" ? latestData.body.etag : undefined;
const r = await lib.instance.getCurrentInstanceKycStatus(token!, {
- longpoll: {
- type: "state-change",
- etag: latestData.body.etag,
- timeout: LONG_POLL_DELAY,
- },
+ longpoll: etag
+ ? {
+ type: "state-change",
+ etag: etag,
+ timeout: LONG_POLL_DELAY,
+ }
+ : undefined,
cancellationToken: ct,
});
- await mutate(r, { revalidate: false });
return r;
},
- [],
{ minTime: LONG_POLL_DELAY },
);
-
- if (error) {
- return error;
- }
+ console.log("res", result);
return result;
}
-export function useInstanceKYCDetails() {
- const { state, lib } = useSessionContext();
- const token = state.token;
-
- async function fetcher([token]: [AccessToken]) {
- return await lib.instance.getCurrentInstanceKycStatus(token);
- }
-
- const { data, error } = useSWR<
- TalerMerchantManagementResultByMethod<"getCurrentInstanceKycStatus">,
- TalerHttpError
- >([token, "getCurrentInstanceKycStatus"], fetcher);
-
- if (error) return error;
- return data;
-}
-
export function revalidateManagedInstanceDetails() {
return mutate(
(key) => Array.isArray(key) && key[key.length - 1] === "getInstanceDetails",
diff --git a/packages/taler-merchant-webui/src/hooks/order.ts b/packages/taler-merchant-webui/src/hooks/order.ts
@@ -66,38 +66,30 @@ export function useOrderDetailsWithLongPoll(orderId: string) {
const { state, lib } = useSessionContext();
const token = state.token;
- async function fetcher([dId, token]: [string, AccessToken]) {
- return await lib.instance.getOrderDetails(token, dId);
- }
-
- const { data, error, mutate } = useSWR<
- TalerMerchantManagementResultByMethod<"getOrderDetails">,
- TalerHttpError
- >([orderId, token, "getOrderDetails"], fetcher);
+ type Res =
+ | undefined
+ | Awaited<ReturnType<typeof lib.instance.getOrderDetails>>;
const result = useLongPolling(
- data,
- (result) => {
- return result !== undefined && result.type === "ok";
- },
- async (ct, latestStatus) => {
- if (latestStatus === undefined || latestStatus.type === "fail")
- return undefined;
+ async (ct, latestStatus): Promise<Res> => {
+ const etag =
+ latestStatus === undefined || latestStatus.type === "fail"
+ ? undefined
+ : latestStatus.body.etag;
const r = await lib.instance.getOrderDetails(token!, orderId, {
- longpoll: {
- etag: latestStatus.body.etag!,
- timeout: LONG_POLL_DELAY,
- },
+ longpoll: !etag
+ ? undefined
+ : {
+ etag,
+ timeout: LONG_POLL_DELAY,
+ },
cancellationToken: ct,
});
- await mutate(r, { revalidate: false });
return r;
},
- [orderId],
- { minTime: LONG_POLL_DELAY },
+ { minTime: LONG_POLL_DELAY, deps: [orderId] },
);
- if (error) return error;
return result;
}
@@ -133,31 +125,14 @@ export function useInstanceOrders(args?: InstanceOrderFilter) {
"listOrders",
];
- async function fetcher([pointer, paid, refunded, wired, date]: any) {
- return await lib.instance.listOrders(token, {
- limit: PAGINATED_LIST_REQUEST,
- offset: pointer?.id,
- order: pointer?.order,
- paid: paid,
- refunded: refunded,
- wired: wired,
- date: date,
- });
- }
-
- const { data, error } = useSWR<
- TalerMerchantManagementResultByMethod<"listOrders">,
- TalerHttpError
- >(cacheKey, fetcher);
+ type Res =
+ | undefined
+ | Awaited<ReturnType<typeof lib.instance.listOrders>>;
const result = useLongPolling(
- data,
- (result) => {
- return result !== undefined && result.type === "ok";
- },
- async (ct, latestStatus, args) => {
- if (latestStatus === undefined || latestStatus.type === "fail")
- return undefined;
+ async (ct, latestStatus, args): Promise<Res> => {
+ const noRes = latestStatus === undefined || latestStatus.type === "fail"
+ const prev = noRes ? [] : latestStatus.body.orders
const params: ListOrdersRequestParams = {
limit: 1,
@@ -170,23 +145,20 @@ export function useInstanceOrders(args?: InstanceOrderFilter) {
};
const result = await lib.instance.listOrders(token, {
...params,
- timeout: LONG_POLL_DELAY,
+ timeout: noRes ? undefined : LONG_POLL_DELAY,
ct,
});
const merged =
result.type === "fail" || result.body.orders.length === 0
- ? latestStatus.body.orders
- : [...result.body.orders, ...latestStatus.body.orders];
+ ? prev
+ : [...result.body.orders, ...prev];
const newResult = opFixedSuccess(dummyHttpResponse, { orders: merged });
- mutate(newResult, { revalidate: false });
return newResult;
},
- cacheKey,
- { minTime: LONG_POLL_DELAY },
+ { minTime: LONG_POLL_DELAY, deps: cacheKey },
);
- if (error || result instanceof TalerError) return error;
if (result === undefined) return undefined;
if (result.type !== "ok") return result;
diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts
@@ -2272,6 +2272,23 @@ export interface MerchantAccountKycRedirect {
// accounts will do.
// Optional. Since protocol **v17**.
payto_kycauths?: string[];
+
+ // Forwarded value of the kyc_swap_tos_acceptance flag
+ // that the merchant backend observed in the exchange's
+ // /keys response. Signals to the frontend that it
+ // should swap the terms-of-service and KYC auth
+ // authentication steps in the user experience.
+ // Optional, defaults to false if not given.
+ // @since protocol **v31**.
+ kyc_swap_tos_acceptance?: boolean;
+
+ // Taler-Terms-Version (see exchange's /terms
+ // endpoint) of the terms of service that were already
+ // accepted by the user for this exchange. Optional,
+ // absent if no version has been accepted yet by the
+ // user via POST /private/accept-tos-early.
+ // @since protocol **v31**.
+ tos_accepted_early?: string;
}
export interface ExchangeKycTimeout {
@@ -4778,6 +4795,11 @@ export const codecForMerchantAccountKycRedirect =
.property("access_token", codecOptional(codecForAccessToken()))
.property("limits", codecOptional(codecForList(codecForAccountLimit())))
.property("payto_kycauths", codecOptional(codecForList(codecForString())))
+ .property(
+ "kyc_swap_tos_acceptance",
+ codecOptionalDefault(codecForBoolean(), false),
+ )
+ .property("tos_accepted_early", codecOptional(codecForString()))
.build("TalerMerchantApi.MerchantAccountKycRedirect");
export const codecForTokenScope = codecForEither(
diff --git a/packages/web-util/src/hooks/useAsync.ts b/packages/web-util/src/hooks/useAsync.ts
@@ -59,7 +59,7 @@ export function useAsync<Res>(
}
export const LONG_POLL_DELAY = 15000;
-
+const emptyArray: unknown[] = []
/**
* First start with `initial` value, if initial is undefined then finish.
* Otherwise:
@@ -80,24 +80,58 @@ export const LONG_POLL_DELAY = 15000;
* @returns
*/
export function useLongPolling<Res>(
- initial: Res,
- shouldRetryFn: (res: Res) => boolean,
- retryFn: (ct: CancellationToken, last: Res, deps: Array<any>) => Promise<Res>,
- deps: Array<any> = [],
- opts: { minTime?: number } = {},
-) {
- const minTime = opts?.minTime ?? 1000;
-
- const [result, setResult] = useState<{ counter: number; prevResult: Res }>({
+ retryFn: (
+ ct: CancellationToken,
+ last: undefined | Res,
+ deps: Array<any>,
+ ) => Promise<Res>,
+ opts: {
+ /**
+ * Wait time in case the request reply fast. Default LONG_POLL_DELAY
+ */
+ minTime?: number;
+ /**
+ * Initial value. Default undefined
+ */
+ initial?: Res;
+ /**
+ * Check before any request if loop should stop retrying. Default always false.
+ */
+ shouldStop?: (res: Res) => boolean;
+ /**
+ * Dependency array to re-evaluate the state. Default empty.
+ */
+ deps?: Array<unknown>;
+ /**
+ * Setting to true will add wait time to the first request. Default false.
+ *
+ * Usually first request should return without delay to have the initial data
+ * and the second request should start next with long poll.
+ */
+ waitFirstRequest?: boolean;
+ } = {},
+): Res | undefined {
+ const minTime = opts?.minTime ?? LONG_POLL_DELAY;
+ const dependencies = (opts?.deps ?? emptyArray)
+ const [result, setResult] = useState<{
+ counter: number;
+ prevResult: Res | undefined;
+ }>({
counter: 0,
- prevResult: initial,
+ prevResult: opts.initial,
});
function retry(prevResult: Res) {
setResult((prev) => ({ counter: prev.counter + 1, prevResult }));
}
- useEffect(() => {
- retry(initial);
- }, [initial, ...deps]);
+ useEffect(
+ () => {
+ setResult((prev) => ({
+ counter: prev.counter,
+ prevResult: opts.initial,
+ }));
+ },
+ [opts.initial, ...dependencies],
+ );
const ct = useRef<{
ct: CancellationToken.Source | undefined;
@@ -109,34 +143,34 @@ export function useLongPolling<Res>(
const tk = CancellationToken.create();
ct.current.ct = tk;
- const doWeRetry = shouldRetryFn(result.prevResult);
+ const doWeRetry =
+ !opts.shouldStop || !result.prevResult
+ ? true
+ : !opts.shouldStop(result.prevResult);
if (!doWeRetry) return;
const diff = new Date().getTime() - ct.current.startMs;
- if (ct.current.startMs === 0 || diff > minTime) {
+ const instant = Promise.resolve();
+ const noTryYet = ct.current.startMs === 0;
+ const enoughTimePassed = diff > minTime;
+ const firstRequestInstant = result.counter === 1 && !opts?.waitFirstRequest;
+
+ console.log("asd", noTryYet, enoughTimePassed, firstRequestInstant);
+ (noTryYet || enoughTimePassed || firstRequestInstant
+ ? instant
+ : delayMs(minTime - diff)
+ ).then(() => {
+ console.log("current", ct.current)
+ if (ct.current.unloaded) return;
ct.current.startMs = new Date().getTime();
- retryFn(tk.token, result.prevResult, deps)
+ retryFn(tk.token, result.prevResult, dependencies)
.then((r) => {
if (!tk.token.isCancelled) {
retry(r);
}
})
.catch((error) => console.log("ERROR", error));
- } else {
- // calling too fast, wait whats left to reach minTime
- delayMs(minTime - diff).then(() => {
- if (ct.current.unloaded) return;
- ct.current.startMs = new Date().getTime();
- retryFn(tk.token, result.prevResult, deps)
- .then((r) => {
- if (!tk.token.isCancelled) {
- retry(r);
- }
- })
- .catch((error) => console.log("ERROR", error));
- });
- }
-
+ });
return () => {};
}, [result.counter]);
@@ -148,7 +182,7 @@ export function useLongPolling<Res>(
ct.current.unloaded = true;
ct.current.startMs = 0;
};
- }, []);
+ }, emptyArray);
/**
* request dependency changed
@@ -160,7 +194,7 @@ export function useLongPolling<Res>(
ct.current.unloaded = true;
ct.current.startMs = 0;
};
- }, deps);
+ }, dependencies);
return result.prevResult;
}