commit 57b439c7ca540c97a3dcac9a8f874fa17f46aa58
parent 19c8f7c9ccd0eed51aa47c6a3b9efb8a6040e4df
Author: Florian Dold <dold@taler.net>
Date: Sun, 23 Aug 2026 12:56:11 +0200
web-util: harden shared runtime behavior
Diffstat:
11 files changed, 309 insertions(+), 304 deletions(-)
diff --git a/packages/web-util/src/components/CopyButton.tsx b/packages/web-util/src/components/CopyButton.tsx
@@ -1,6 +1,7 @@
import { ComponentChildren, h, VNode } from "preact";
import { CSSProperties } from "preact/compat";
import { useEffect, useState } from "preact/hooks";
+import { useTranslationContext } from "../context/translation.js";
export function CopyIcon(): VNode {
return (
@@ -52,24 +53,22 @@ export function CopyButton({
getContent: () => string;
}): VNode {
const [copied, setCopied] = useState(false);
- function copyText(): void {
- if (!navigator.clipboard && !window.isSecureContext) {
- prompt(
- "Clipboard is not available on insecure context (http).",
- getContent(),
- );
- }
- if (navigator.clipboard) {
- navigator.clipboard.writeText(getContent() || "");
+ const { i18n } = useTranslationContext();
+ async function copyText(): Promise<void> {
+ const content = getContent() || "";
+ try {
+ if (!navigator.clipboard) throw Error("Clipboard API unavailable");
+ await navigator.clipboard.writeText(content);
setCopied(true);
+ } catch (error) {
+ console.error("Could not write to the clipboard", error);
+ prompt(i18n.str`Copy this text manually:`, content);
}
}
useEffect(() => {
- if (copied) {
- setTimeout(() => {
- setCopied(false);
- }, 1000);
- }
+ if (!copied) return;
+ const timer = setTimeout(() => setCopied(false), 1000);
+ return () => clearTimeout(timer);
}, [copied]);
if (!copied) {
@@ -79,7 +78,7 @@ export function CopyButton({
style={style}
onClick={(e) => {
e.preventDefault();
- copyText();
+ void copyText();
}}
>
<CopyIcon />
diff --git a/packages/web-util/src/context/activity.ts b/packages/web-util/src/context/activity.ts
@@ -34,16 +34,13 @@ export class ActiviyTracker<Event> {
this.subscribe = this.subscribe.bind(this);
}
notify(data: Event): void {
- this.observers.forEach((observer) => observer(data));
+ this.observers.slice().forEach((observer) => observer(data));
}
subscribe(func: Listener<Event>): Unsuscriber {
this.observers.push(func);
return () => {
- this.observers.forEach((observer, index) => {
- if (observer === func) {
- this.observers.splice(index, 1);
- }
- });
+ const index = this.observers.indexOf(func);
+ if (index !== -1) this.observers.splice(index, 1);
};
}
}
diff --git a/packages/web-util/src/context/bank-api.ts b/packages/web-util/src/context/bank-api.ts
@@ -33,7 +33,7 @@ import {
createContext,
h,
} from "preact";
-import { useContext, useEffect, useMemo, useState } from "preact/hooks";
+import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { ErrorLoading } from "../components/ErrorLoading.js";
import { BrowserFetchHttpLib } from "../utils/http-impl.sw.js";
import { APIClient, ActiviyTracker, BankLib, Subscriber } from "./activity.js";
@@ -67,6 +67,7 @@ type Evictors = {
conversion?: CacheEvictor<TalerBankConversionCacheEviction>;
bank?: CacheEvictor<TalerCoreBankCacheEviction>;
};
+const NO_EVICTORS: Evictors = {};
type ConfigResult<T> =
| undefined
@@ -80,7 +81,7 @@ export const BankApiProvider = ({
baseUrl,
children,
frameOnError,
- evictors = {},
+ evictors = NO_EVICTORS,
}: {
baseUrl: URL;
children: ComponentChildren;
@@ -89,18 +90,25 @@ export const BankApiProvider = ({
}): VNode => {
const [checked, setChecked] =
useState<ConfigResult<TalerCorebankApi.TalerCorebankConfigResponse>>();
+ const checkedFor = useRef<string>();
+ const endpointKey = baseUrl.href;
const { i18n } = useTranslationContext();
const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } = useMemo(
() => buildBankApiClient(baseUrl, evictors),
- [baseUrl.href],
+ [baseUrl.href, evictors.bank, evictors.conversion],
);
useEffect(() => {
- let keepRetrying = true;
+ let active = true;
+ let retryTimer: ReturnType<typeof setTimeout> | undefined;
+ setChecked(undefined);
+ checkedFor.current = undefined;
async function testConfig(): Promise<void> {
try {
const config = await getRemoteConfig();
+ if (!active) return;
+ checkedFor.current = endpointKey;
if (LibtoolVersion.compare(VERSION, config.version)) {
setChecked({ type: "ok", config, hints: [] });
} else {
@@ -111,12 +119,10 @@ export const BankApiProvider = ({
});
}
} catch (error) {
+ if (!active) return;
+ checkedFor.current = endpointKey;
if (error instanceof TalerError) {
- if (keepRetrying) {
- setTimeout(() => {
- testConfig();
- }, CONFIG_FAIL_TRY_AGAIN_MS);
- }
+ retryTimer = setTimeout(testConfig, CONFIG_FAIL_TRY_AGAIN_MS);
setChecked({ type: "error", error });
} else {
setChecked({ type: "error", error: TalerError.fromException(error) });
@@ -126,11 +132,15 @@ export const BankApiProvider = ({
testConfig();
return () => {
// on unload, stop retry
- keepRetrying = false;
+ active = false;
+ if (retryTimer !== undefined) clearTimeout(retryTimer);
};
- }, []);
+ }, [getRemoteConfig, VERSION, endpointKey]);
- if (checked === undefined) {
+ const currentChecked =
+ checkedFor.current === endpointKey ? checked : undefined;
+
+ if (currentChecked === undefined) {
return h(frameOnError, {
children: h(
"div",
@@ -139,31 +149,31 @@ export const BankApiProvider = ({
),
});
}
- if (checked.type === "error") {
+ if (currentChecked.type === "error") {
return h(frameOnError, {
children: h(ErrorLoading, {
title: i18n.str`There was an error trying to contact the backend service.`,
- error: checked.error,
+ error: currentChecked.error,
}),
});
}
- if (checked.type === "incompatible") {
+ if (currentChecked.type === "incompatible") {
return h(frameOnError, {
children: h(
"div",
{},
- i18n.str`The server version is not supported. Supported version "${checked.supported}", server version "${checked.result.version}"`,
+ i18n.str`The server version is not supported. Supported version "${currentChecked.supported}", server version "${currentChecked.result.version}"`,
),
});
}
const value: BankContextType = {
url: baseUrl,
- config: checked.config,
+ config: currentChecked.config,
onActivity: onActivity,
lib,
cancelRequest,
- hints: checked.hints,
+ hints: currentChecked.hints,
};
return h(BankContext.Provider, {
value,
@@ -229,7 +239,7 @@ function buildBankApiClient(
},
},
onActivity: tracker.subscribe,
- cancelRequest: httpLib.cancelRequest,
+ cancelRequest: (id) => httpLib.cancelRequest(id),
};
}
diff --git a/packages/web-util/src/context/challenger-api.ts b/packages/web-util/src/context/challenger-api.ts
@@ -31,7 +31,7 @@ import {
createContext,
h,
} from "preact";
-import { useContext, useEffect, useMemo, useState } from "preact/hooks";
+import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { ErrorLoading } from "../components/ErrorLoading.js";
import { BrowserFetchHttpLib } from "../utils/http-impl.sw.js";
import {
@@ -69,6 +69,7 @@ enum VersionHint {
type Evictors = {
challenger?: CacheEvictor<ChallengerCacheEviction>;
};
+const NO_EVICTORS: Evictors = {};
type ConfigResult<T> =
| undefined
@@ -82,7 +83,7 @@ export const ChallengerApiProvider = ({
baseUrl,
children,
frameOnError,
- evictors = {},
+ evictors = NO_EVICTORS,
}: {
baseUrl: URL;
children: ComponentChildren;
@@ -91,18 +92,25 @@ export const ChallengerApiProvider = ({
}): VNode => {
const [checked, setChecked] =
useState<ConfigResult<ChallengerApi.ChallengerTermsOfServiceResponse>>();
+ const checkedFor = useRef<string>();
+ const endpointKey = baseUrl.href;
const { i18n } = useTranslationContext();
const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } = useMemo(
() => buildChallengerApiClient(baseUrl, evictors),
- [baseUrl.href],
+ [baseUrl.href, evictors.challenger],
);
useEffect(() => {
- let keepRetrying = true;
+ let active = true;
+ let retryTimer: ReturnType<typeof setTimeout> | undefined;
+ setChecked(undefined);
+ checkedFor.current = undefined;
async function testConfig(): Promise<void> {
try {
const config = await getRemoteConfig();
+ if (!active) return;
+ checkedFor.current = endpointKey;
if (LibtoolVersion.compare(VERSION, config.version)) {
setChecked({ type: "ok", config, hints: [] });
} else {
@@ -113,12 +121,10 @@ export const ChallengerApiProvider = ({
});
}
} catch (error) {
+ if (!active) return;
+ checkedFor.current = endpointKey;
if (error instanceof TalerError) {
- if (keepRetrying) {
- setTimeout(() => {
- testConfig();
- }, CONFIG_FAIL_TRY_AGAIN_MS);
- }
+ retryTimer = setTimeout(testConfig, CONFIG_FAIL_TRY_AGAIN_MS);
setChecked({ type: "error", error });
} else {
setChecked({ type: "error", error: TalerError.fromException(error) });
@@ -128,11 +134,15 @@ export const ChallengerApiProvider = ({
testConfig();
return () => {
// on unload, stop retry
- keepRetrying = false;
+ active = false;
+ if (retryTimer !== undefined) clearTimeout(retryTimer);
};
- }, []);
+ }, [getRemoteConfig, VERSION, endpointKey]);
- if (checked === undefined) {
+ const currentChecked =
+ checkedFor.current === endpointKey ? checked : undefined;
+
+ if (currentChecked === undefined) {
return h(frameOnError, {
children: h(
"div",
@@ -141,31 +151,31 @@ export const ChallengerApiProvider = ({
),
});
}
- if (checked.type === "error") {
+ if (currentChecked.type === "error") {
return h(frameOnError, {
children: h(ErrorLoading, {
title: i18n.str`There was an error trying to contact the backend service.`,
- error: checked.error,
+ error: currentChecked.error,
}),
});
}
- if (checked.type === "incompatible") {
+ if (currentChecked.type === "incompatible") {
return h(frameOnError, {
children: h(
"div",
{},
- i18n.str`The server version is not supported. Supported version "${checked.supported}", server version "${checked.result.version}"`,
+ i18n.str`The server version is not supported. Supported version "${currentChecked.supported}", server version "${currentChecked.result.version}"`,
),
});
}
const value: ChallengerContextType = {
url: baseUrl,
- config: checked.config,
+ config: currentChecked.config,
onActivity: onActivity,
lib,
cancelRequest,
- hints: checked.hints,
+ hints: currentChecked.hints,
};
return h(ChallengerContext.Provider, {
value,
@@ -215,19 +225,6 @@ function buildChallengerApiClient(
challenger,
},
onActivity: tracker.subscribe,
- cancelRequest: httpLib.cancelRequest,
+ cancelRequest: (id) => httpLib.cancelRequest(id),
};
}
-
-export const ChallengerApiProviderTesting = ({
- children,
- value,
-}: {
- value: ChallengerContextType;
- children: ComponentChildren;
-}): VNode => {
- return h(ChallengerContext.Provider, {
- value,
- children,
- });
-};
diff --git a/packages/web-util/src/context/exchange-api.ts b/packages/web-util/src/context/exchange-api.ts
@@ -32,7 +32,7 @@ import {
createContext,
h,
} from "preact";
-import { useContext, useEffect, useMemo, useState } from "preact/hooks";
+import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { ErrorLoading } from "../components/ErrorLoading.js";
import { BrowserFetchHttpLib } from "../utils/http-impl.sw.js";
import { useTranslationContext } from "./translation.js";
@@ -80,6 +80,7 @@ enum VersionHint {
type Evictors = {
exchange?: CacheEvictor<TalerExchangeCacheEviction>;
};
+const NO_EVICTORS: Evictors = {};
type ConfigResult<T> =
| undefined
@@ -100,7 +101,7 @@ export type KeysAndConfigType = {
export const ExchangeApiProvider = ({
baseUrl,
children,
- evictors = {},
+ evictors = NO_EVICTORS,
frameOnError,
preventCompression,
}: {
@@ -111,18 +112,29 @@ export const ExchangeApiProvider = ({
preventCompression?: boolean;
}): VNode => {
const [checked, setChecked] = useState<ConfigResult<KeysAndConfigType>>();
+ const checkedFor = useRef<string>();
+ const endpointKey = `${baseUrl.href}:${!!preventCompression}`;
const { i18n } = useTranslationContext();
const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } = useMemo(
() => buildExchangeApiClient(baseUrl, evictors, !!preventCompression),
- [baseUrl.href, preventCompression],
+ [baseUrl.href, evictors.exchange, preventCompression],
+ );
+ const { lib: unthrottledApi } = useMemo(
+ () => buildExchangeApiClient(baseUrl, evictors, !!preventCompression, true),
+ [baseUrl.href, evictors.exchange, preventCompression],
);
useEffect(() => {
- let keepRetrying = true;
+ let active = true;
+ let retryTimer: ReturnType<typeof setTimeout> | undefined;
+ setChecked(undefined);
+ checkedFor.current = undefined;
async function testConfig(): Promise<void> {
try {
const config = await getRemoteConfig();
+ if (!active) return;
+ checkedFor.current = endpointKey;
if (LibtoolVersion.compare(VERSION, config.config.version)) {
setChecked({ type: "ok", config, hints: [] });
} else {
@@ -133,12 +145,10 @@ export const ExchangeApiProvider = ({
});
}
} catch (error) {
+ if (!active) return;
+ checkedFor.current = endpointKey;
if (error instanceof TalerError) {
- if (keepRetrying) {
- setTimeout(() => {
- testConfig();
- }, CONFIG_FAIL_TRY_AGAIN_MS);
- }
+ retryTimer = setTimeout(testConfig, CONFIG_FAIL_TRY_AGAIN_MS);
setChecked({ type: "error", error });
} else {
setChecked({ type: "error", error: TalerError.fromException(error) });
@@ -148,11 +158,15 @@ export const ExchangeApiProvider = ({
testConfig();
return () => {
// on unload, stop retry
- keepRetrying = false;
+ active = false;
+ if (retryTimer !== undefined) clearTimeout(retryTimer);
};
- }, []);
+ }, [getRemoteConfig, VERSION, endpointKey]);
+
+ const currentChecked =
+ checkedFor.current === endpointKey ? checked : undefined;
- if (checked === undefined) {
+ if (currentChecked === undefined) {
return h(frameOnError, {
children: h(
"div",
@@ -161,21 +175,21 @@ export const ExchangeApiProvider = ({
),
});
}
- if (checked.type === "error") {
+ if (currentChecked.type === "error") {
return h(frameOnError, {
children: h(ErrorLoading, {
title: i18n.str`There was an error trying to contact the backend service.`,
- error: checked.error,
+ error: currentChecked.error,
}),
});
}
- if (checked.type === "incompatible") {
- const title = i18n.str`The server version is not supported. Supported version "${checked.supported}", server version "${checked.result.config.version}"`;
+ if (currentChecked.type === "incompatible") {
+ const title = i18n.str`The server version is not supported. Supported version "${currentChecked.supported}", server version "${currentChecked.result.config.version}"`;
const error = TalerError.fromDetail(
TalerErrorCode.GENERIC_CLIENT_UNSUPPORTED_PROTOCOL_VERSION,
{
- client: checked.supported,
- server: checked.result.config.version,
+ client: currentChecked.supported,
+ server: currentChecked.result.config.version,
},
title,
);
@@ -187,19 +201,14 @@ export const ExchangeApiProvider = ({
});
}
- const { lib: unthrottledApi } = useMemo(
- () => buildExchangeApiClient(baseUrl, evictors, !!preventCompression, true),
- [baseUrl.href, preventCompression],
- );
-
const value: ExchangeContextType = {
url: baseUrl,
- config: checked.config,
+ config: currentChecked.config,
onActivity: onActivity,
lib,
unthrottledApi,
cancelRequest,
- hints: checked.hints,
+ hints: currentChecked.hints,
};
return h(ExchangeContext.Provider, {
value,
@@ -265,7 +274,7 @@ function buildExchangeApiClient(
exchange: ex,
},
onActivity: tracker.subscribe,
- cancelRequest: httpLib.cancelRequest,
+ cancelRequest: (id) => httpLib.cancelRequest(id),
};
}
diff --git a/packages/web-util/src/context/translation.ts b/packages/web-util/src/context/translation.ts
@@ -16,7 +16,8 @@
import { i18n, setupI18n } from "@gnu-taler/taler-util";
import { ComponentChildren, createContext, h, VNode } from "preact";
-import { useContext, useEffect } from "preact/hooks";
+import { useContext, useEffect, useMemo } from "preact/hooks";
+import { strings as webUtilStrings, StringsType } from "../i18n/strings.js";
import { useLang } from "../hooks/index.js";
import { Locale } from "date-fns";
import {
@@ -63,16 +64,55 @@ const initial: Type = {
};
const Context = createContext<Type>(initial);
-interface LangInfo {
- lang: string;
- completeness: number;
-}
interface Props {
initial?: string;
children: ComponentChildren;
/** ONLY USER FOR TESTING */
forceLang__testing?: string;
- source: Record<string, LangInfo>;
+ source: Record<string, StringsType>;
+}
+
+function mergeTranslationSources(
+ application: Record<string, StringsType>,
+): Record<string, StringsType> {
+ const result: Record<string, StringsType> = {};
+ for (const lang of new Set([
+ ...Object.keys(webUtilStrings),
+ ...Object.keys(application),
+ ])) {
+ const shared = webUtilStrings[lang];
+ const host = application[lang];
+ if (!shared) {
+ result[lang] = host;
+ continue;
+ }
+ if (!host) {
+ result[lang] = shared;
+ continue;
+ }
+ const messages = {
+ ...shared.locale_data.messages,
+ ...host.locale_data.messages,
+ };
+ const translated = Object.entries(messages).filter(
+ ([key, value]) =>
+ key !== "" &&
+ Array.isArray(value) &&
+ value.some((entry) => typeof entry === "string" && entry.length > 0),
+ ).length;
+ const total = Math.max(1, Object.keys(messages).length - 1);
+ result[lang] = {
+ ...shared,
+ ...host,
+ completeness: Math.round((translated * 100) / total),
+ locale_data: {
+ ...shared.locale_data,
+ ...host.locale_data,
+ messages,
+ },
+ };
+ }
+ return result;
}
// Outmost UI wrapper.
@@ -82,10 +122,15 @@ export const TranslationProvider = ({
forceLang__testing: forceLang,
source,
}: Props): VNode => {
+ const mergedSource = useMemo(() => mergeTranslationSources(source), [source]);
const completeness = Object.keys(SUPPORTED_LANGS).reduce(
(map, lang) => {
- if (lang !== "en" && source[lang] && source[lang].completeness) {
- map[lang] = source[lang].completeness;
+ if (
+ lang !== "en" &&
+ mergedSource[lang] &&
+ mergedSource[lang].completeness
+ ) {
+ map[lang] = mergedSource[lang].completeness;
}
return map;
},
@@ -102,27 +147,21 @@ export const TranslationProvider = ({
changeLanguage(forceLang);
}
}, [forceLang]);
- useEffect(() => {
- setupI18n(lang, source);
- }, [lang]);
- if (forceLang) {
- setupI18n(forceLang, source);
- } else {
- setupI18n(lang, source);
- }
+ const effectiveLang = forceLang ?? lang;
+ setupI18n(effectiveLang, mergedSource);
const dateLocale =
- lang === "es"
+ effectiveLang === "es"
? esLocale
- : lang === "fr"
+ : effectiveLang === "fr"
? frLocale
- : lang === "de"
+ : effectiveLang === "de"
? deLocale
: enLocale;
return h(Context.Provider, {
value: {
- lang,
+ lang: effectiveLang,
changeLanguage,
supportedLang: SUPPORTED_LANGS,
i18n,
diff --git a/packages/web-util/src/hooks/useAsync.ts b/packages/web-util/src/hooks/useAsync.ts
@@ -13,7 +13,7 @@
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { CancellationToken, TalerError } from "@gnu-taler/taler-util";
+import { CancellationToken } from "@gnu-taler/taler-util";
import {
EffectCallback,
Inputs,
@@ -22,47 +22,6 @@ import {
useState,
} from "preact/hooks";
-/**
- * convert the async function into preact hook
- *
- * @param callback the async function
- *
- * @returns operation status
- */
-export function useAsync<Res>(
- callback: (() => Promise<Res>) | undefined,
- deps: Array<any> = [],
-) {
- const [data, setData] = useState<Res>();
- const [error, setError] = useState<TalerError>();
-
- useEffect(() => {
- let unloaded = false;
- if (callback) {
- callback()
- .then((resp) => {
- if (unloaded) return;
- setData(resp);
- })
- .catch((error: unknown) => {
- if (unloaded) return;
- if (error instanceof TalerError) {
- setError(error);
- } else {
- setError(TalerError.fromException(error));
- }
- });
- }
- return () => {
- unloaded = true;
- };
- }, deps);
-
- if (error) return error;
- if (!data) return undefined;
- return data;
-}
-
export const LONG_POLL_DELAY = 15000;
const LP_MIN_DELAY = 5000;
const emptyArray: unknown[] = [];
@@ -163,14 +122,17 @@ export function useLongPolling<Res>(
ct.current.startMs = new Date().getTime();
Promise.race([p, cancelOnTimeout(LONG_POLL_DELAY + 100, tk)])
.then(retry)
- .catch((error) => {
- if (
- error !== "long-polling-timeout" ||
- error !== "long-polling-stop"
- ) {
+ .catch((error: unknown) => {
+ const reason =
+ error instanceof CancellationToken.CancellationError
+ ? error.reason
+ : error instanceof Error
+ ? error.message
+ : error;
+ if (reason === "long-polling-stop" || ct.current.unloaded) return;
+ if (reason !== "long-polling-timeout") {
console.error("Long polling error", error);
}
- // consider checking "shouldStop" with error
retry(result.prevResult);
});
}
@@ -193,16 +155,24 @@ export async function delayMs(
ct?: CancellationToken,
): Promise<void> {
return new Promise((resolve, reject) => {
- setTimeout(() => resolve(), ms);
+ let unregister: () => void = () => undefined;
+ const timer = setTimeout(() => {
+ unregister();
+ resolve();
+ }, ms);
if (ct) {
- ct.onCancelled(reject);
+ unregister = ct.onCancelled((reason) => {
+ clearTimeout(timer);
+ unregister();
+ reject(new CancellationToken.CancellationError(reason));
+ });
}
});
}
async function cancelOnTimeout(ms: number, tk: CancellationToken.Source) {
return delayMs(ms).then((_) => {
tk.cancel("long-polling-timeout");
- throw Error("long-polling-timeout");
+ throw new CancellationToken.CancellationError("long-polling-timeout");
});
}
/**
@@ -211,7 +181,10 @@ async function cancelOnTimeout(ms: number, tk: CancellationToken.Source) {
* @param effectCallback
* @param inputs
*/
-function useEffectOnDepChanged(effectCallback: EffectCallback, inputs?: Inputs) {
+function useEffectOnDepChanged(
+ effectCallback: EffectCallback,
+ inputs?: Inputs,
+) {
const isMounted = useRef(false);
useEffect(() => {
diff --git a/packages/web-util/src/hooks/useAsyncAsHook.ts b/packages/web-util/src/hooks/useAsyncAsHook.ts
@@ -13,9 +13,15 @@
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { TalerErrorDetail, TalerHttpError } from "@gnu-taler/taler-util";
+import { TalerErrorDetail } from "@gnu-taler/taler-util";
// import { TalerError } from "@gnu-taler/taler-wallet-core";
-import { useEffect, useMemo, useState } from "preact/hooks";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "preact/hooks";
export interface HookOk<T> {
hasError: false;
@@ -45,14 +51,14 @@ export type HookResponseWithRetry<T> =
| ((HookOk<T> | HookError) & WithRetry)
| undefined;
-/**
- * @deprecated use useAsyncWithRetry
- */
+/** Convert an asynchronous request into a render-friendly response state. */
export function useAsyncAsHook<T>(
fn: () => Promise<T | false>,
deps?: any[],
): HookResponseWithRetry<T> {
const [result, setHookResponse] = useState<HookResponse<T>>(undefined);
+ const [retryCounter, setRetryCounter] = useState(0);
+ const generation = useRef(0);
const args = useMemo(
() => ({
@@ -61,87 +67,35 @@ export function useAsyncAsHook<T>(
deps || [],
);
- async function doAsync(): Promise<void> {
- try {
- const response = await args.fn();
- if (response === false) return;
- setHookResponse({ hasError: false, response });
- } catch (e) {
- // if (e instanceof TalerError) {
- // setHookResponse({
- // hasError: true,
- // operational: true,
- // details: e.errorDetail,
- // });
- // } else
- if (e instanceof Error) {
+ useEffect(() => {
+ const currentGeneration = ++generation.current;
+ let active = true;
+ setHookResponse(undefined);
+ args.fn().then(
+ (response) => {
+ if (!active || generation.current !== currentGeneration) return;
+ if (response !== false) {
+ setHookResponse({ hasError: false, response });
+ }
+ },
+ (error: unknown) => {
+ if (!active || generation.current !== currentGeneration) return;
setHookResponse({
hasError: true,
operational: false,
- message: e.message,
+ message: error instanceof Error ? error.message : String(error),
});
- }
- }
- }
-
- useEffect(() => {
- doAsync();
- }, [args]);
-
- if (!result) return undefined;
- return { ...result, retry: doAsync };
-}
-
-/**
- * @deprecated
- *
- * Convert an async function named $fetcher into a hook behavior
- * with a retry function condition.
- *
- * The $retry function is called every time $fetcher finalize
- * and if $retry returns true the $fetcher is called again
- *
- * @param fetcher
- * @param retry
- * @returns
- */
-export function useAsyncWithRetry<Res>(
- fetcher: (() => Promise<Res>) | undefined,
- retry?: (res: Res | undefined, err?: TalerHttpError | undefined) => boolean,
-): { result: Res | undefined; error: TalerHttpError | undefined } {
- const [result, setResult] = useState<Res>();
- const [error, setError] = useState<TalerHttpError>();
- const [retryCounter, setRetryCounter] = useState(0);
-
- let unloaded = false;
- useEffect(() => {
- if (fetcher) {
- fetcher()
- .then((resp) => {
- if (unloaded) return;
- setResult(resp);
- })
- .catch((error: TalerHttpError) => {
- if (unloaded) return;
- setError(error);
- });
- }
-
+ },
+ );
return () => {
- unloaded = true;
+ active = false;
};
- }, [fetcher, retryCounter]);
+ }, [args, retryCounter]);
- // retry on result or error
- // FIXME: why we need a second useEffect? this should be merged with the one above
- useEffect(() => {
- if (retry && retry(result, error)) {
- setRetryCounter((c) => c + 1);
- }
- return () => {
- unloaded = true;
- };
- }, [result, error]);
+ const retry = useCallback(() => {
+ setRetryCounter((counter) => counter + 1);
+ }, []);
- return { result, error };
+ if (!result) return undefined;
+ return { ...result, retry };
}
diff --git a/packages/web-util/src/serve.ts b/packages/web-util/src/serve.ts
@@ -10,26 +10,30 @@ import {
setResponseHeader,
serveStatic,
} from "h3";
-import https from "https";
import http from "http";
import { parse } from "node:url";
import WebSocket from "ws";
import { readFile, stat } from "node:fs/promises";
import { join } from "node:path";
-// import locahostCrt from "./keys/localhost.crt";
-// import locahostKey from "./keys/localhost.key";
import storiesHtml from "./stories.html";
import path from "node:path";
-const httpServerOptions = {
- // key: locahostKey,
- // cert: locahostCrt,
-};
-
const logger = new Logger("serve.ts");
+function serialize<T>(callback: () => Promise<T>): () => Promise<T> {
+ let previous: Promise<unknown> = Promise.resolve();
+ return () => {
+ const current = previous.then(callback, callback);
+ previous = current.then(
+ () => undefined,
+ () => undefined,
+ );
+ return current;
+ };
+}
+
function getMimeType(filename: string): string | undefined {
const ext = path.extname(filename).toLowerCase();
const map: Record<string, string> = {
@@ -54,23 +58,21 @@ async function watch(opts: {
const watchingFolder = opts.source ?? opts.folder;
logger.info(`SERVER_DIR detected, server off`);
logger.info(`watching ${watchingFolder} for changes`);
+ const update = opts.onSourceUpdate
+ ? serialize(opts.onSourceUpdate)
+ : undefined;
chokidar.watch(watchingFolder).on("change", (path, stats) => {
logger.info(`changed: ${path}`);
- if (opts.onSourceUpdate) {
- opts.onSourceUpdate();
- }
+ void update?.().catch((error) => logger.error(String(error)));
});
-
- if (opts.onSourceUpdate) opts.onSourceUpdate();
}
export async function serve(opts: {
folder: string;
port: number;
source?: string;
- tls?: boolean;
examplesLocationJs?: string;
examplesLocationCss?: string;
onSourceUpdate?: () => Promise<void>;
@@ -157,9 +159,7 @@ export async function serve(opts: {
app.use(router);
const nodeListener = toNodeListener(app);
- const httpServer = opts.tls
- ? https.createServer(httpServerOptions, nodeListener)
- : http.createServer(nodeListener);
+ const httpServer = http.createServer(nodeListener);
const httpPort = opts.port;
logger.info(`Dev server. Endpoints:`);
@@ -175,7 +175,17 @@ export async function serve(opts: {
httpServer.on("upgrade", function upgrade(request, socket, head) {
const { pathname } = parse(request.url || "");
- if (pathname === PATHS.WS) {
+ const origin = request.headers.origin;
+ let localOrigin = origin === undefined;
+ if (origin !== undefined) {
+ try {
+ const hostname = new URL(origin).hostname;
+ localOrigin = ["localhost", "127.0.0.1", "::1"].includes(hostname);
+ } catch {
+ localOrigin = false;
+ }
+ }
+ if (pathname === PATHS.WS && localOrigin) {
wss.handleUpgrade(request, socket, head, function done(ws) {
wss.emit("connection", ws, request);
});
@@ -197,14 +207,16 @@ export async function serve(opts: {
};
const watchingFolder = opts.source ?? opts.folder;
logger.info(`watching ${watchingFolder} for changes`);
+ const update = opts.onSourceUpdate
+ ? serialize(opts.onSourceUpdate)
+ : undefined;
chokidar.watch(watchingFolder).on("change", (path, stats) => {
logger.info(`changed: ${path}`);
- if (opts.onSourceUpdate) {
+ if (update) {
sendToAllClients({ type: "file-updated-start", data: { path } });
- opts
- .onSourceUpdate()
+ update()
.then((result: any) => {
const { errors, warnings } = result;
sendToAllClients({
@@ -223,16 +235,14 @@ export async function serve(opts: {
}
});
- if (opts.onSourceUpdate) opts.onSourceUpdate();
-
- const socketFile = process.env.SOCKET_FILE
- ? process.env.SOCKET_FILE
- : httpPort;
-
const listenStr = process.env.SOCKET_FILE
? process.env.SOCKET_FILE
: `http://localhost:${httpPort}/`;
logger.info(`Serving ${opts.folder} on ${listenStr} via plain HTTP`);
- httpServer.listen(socketFile);
+ if (process.env.SOCKET_FILE) {
+ httpServer.listen(process.env.SOCKET_FILE);
+ } else {
+ httpServer.listen(httpPort, "127.0.0.1");
+ }
}
diff --git a/packages/web-util/src/tests/mock.ts b/packages/web-util/src/tests/mock.ts
@@ -323,8 +323,6 @@ interface AssertExpectedQueryParamsMismatch {
* helpers
*
*/
-export type Tester = (a: any, b: any) => boolean | undefined;
-
function deepEquals(
a: unknown,
b: unknown,
@@ -366,7 +364,7 @@ function deepEquals(
case "[object Date]": {
const _a = a as Date;
const _b = b as Date;
- return _a == _b;
+ return _a.getTime() === _b.getTime();
}
case "[object RegExp]": {
const _a = a as RegExp;
@@ -476,12 +474,13 @@ function iterable(
const aIterator = (a as any)[IteratorSymbol]();
const bIterator = (b as any)[IteratorSymbol]();
- const nextA = aIterator.next();
- while (nextA.done) {
+ let nextA = aIterator.next();
+ while (!nextA.done) {
const nextB = bIterator.next();
- if (nextB.done || !deepEquals(nextA.value, nextB.value)) {
+ if (nextB.done || !deepEquals(nextA.value, nextB.value, aStack, bStack)) {
return false;
}
+ nextA = aIterator.next();
}
if (!bIterator.next().done) {
return false;
diff --git a/packages/web-util/src/utils/observable.ts b/packages/web-util/src/utils/observable.ts
@@ -1,4 +1,3 @@
-
export type ObservableMap<K, V> = Map<K, V> & {
onAnyUpdate: (callback: () => void) => () => void;
onUpdate: (key: string, callback: () => void) => () => void;
@@ -28,16 +27,12 @@ export function memoryMap<T>(
},
delete: (key: string) => {
const result = backend.delete(key);
- //@ts-ignore
- theMemoryMap.size = backend.size;
obs.dispatchEvent(new Event(`update-${key}`));
obs.dispatchEvent(new Event(`update`));
return result;
},
set: (key: string, value: T) => {
backend.set(key, value);
- //@ts-ignore
- theMemoryMap.size = backend.length;
obs.dispatchEvent(new Event(`update-${key}`));
obs.dispatchEvent(new Event(`update`));
return theMemoryMap;
@@ -51,7 +46,9 @@ export function memoryMap<T>(
get: backend.get.bind(backend),
has: backend.has.bind(backend),
keys: backend.keys.bind(backend),
- size: backend.size,
+ get size() {
+ return backend.size;
+ },
values: backend.values.bind(backend),
[Symbol.iterator]: backend[Symbol.iterator].bind(backend),
[Symbol.toStringTag]: "theMemoryMap",
@@ -94,16 +91,12 @@ export function localStorageMap(): ObservableMap<string, string> {
delete: (key: string) => {
const exists = localStorage.getItem(key) !== null;
localStorage.removeItem(key);
- //@ts-ignore
- theLocalStorageMap.size = localStorage.length;
obs.dispatchEvent(new Event(`update-${key}`));
obs.dispatchEvent(new Event(`update`));
return exists;
},
set: (key: string, v: string) => {
localStorage.setItem(key, v);
- //@ts-ignore
- theLocalStorageMap.size = localStorage.length;
obs.dispatchEvent(new Event(`update-${key}`));
obs.dispatchEvent(new Event(`update`));
return theLocalStorageMap;
@@ -148,7 +141,7 @@ export function localStorageMap(): ObservableMap<string, string> {
//the key exist, this should not happen
throw Error("value cant be null");
}
- cb(key, item, theLocalStorageMap);
+ cb(item, key, theLocalStorageMap);
}
},
get: (key: string) => {
@@ -178,7 +171,9 @@ export function localStorageMap(): ObservableMap<string, string> {
},
};
},
- size: localStorage.length,
+ get size() {
+ return localStorage.length;
+ },
values: () => {
let index = 0;
const total = localStorage.length;
@@ -233,6 +228,14 @@ async function updateContent(obj: Record<string, any>) {
return chrome.storage.local.set(obj);
}
}
+async function removeContent(keys: string[]) {
+ if (keys.length === 0) return;
+ if (isFirefox) {
+ // @ts-ignore
+ return browser.storage.local.remove(keys);
+ }
+ return chrome.storage.local.remove(keys);
+}
type Changes = { [key: string]: { oldValue?: any; newValue?: any } };
function onBrowserStorageUpdate(cb: (changes: Changes) => void): void {
if (isFirefox) {
@@ -246,29 +249,43 @@ function onBrowserStorageUpdate(cb: (changes: Changes) => void): void {
export function browserStorageMap(
backend: ObservableMap<string, string>,
): ObservableMap<string, string> {
+ let applyingRemoteChange = false;
getAllContent().then((content) => {
+ applyingRemoteChange = true;
Object.entries(content ?? {}).forEach(([k, v]) => {
backend.set(k, v as string);
});
+ applyingRemoteChange = false;
});
- backend.onAnyUpdate(async () => {
- // FIXME: dont update all keys #11665
- const result: Record<string, string> = {};
- for (const [key, value] of backend.entries()) {
- result[key] = value;
- }
- await updateContent(result);
+ let pendingSync = Promise.resolve();
+ backend.onAnyUpdate(() => {
+ if (applyingRemoteChange) return;
+ pendingSync = pendingSync
+ .then(async () => {
+ const result: Record<string, string> = {};
+ for (const [key, value] of backend.entries()) result[key] = value;
+ const existing = await getAllContent();
+ const deletedKeys = Object.keys(existing ?? {}).filter(
+ (key) => !(key in result),
+ );
+ await removeContent(deletedKeys);
+ await updateContent(result);
+ })
+ .catch((error) => {
+ console.error("Could not synchronize browser storage", error);
+ });
});
onBrowserStorageUpdate((changes) => {
//another chrome instance made the change
+ applyingRemoteChange = true;
const changedItems = Object.keys(changes);
if (changedItems.length === 0) {
backend.clear();
} else {
for (const key of changedItems) {
- if (!changes[key].newValue) {
+ if (changes[key].newValue === undefined) {
backend.delete(key);
} else {
if (changes[key].newValue !== changes[key].oldValue) {
@@ -277,6 +294,7 @@ export function browserStorageMap(
}
}
}
+ applyingRemoteChange = false;
});
return backend;