taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/web-util/src/components/CopyButton.tsx | 29++++++++++++++---------------
Mpackages/web-util/src/context/activity.ts | 9+++------
Mpackages/web-util/src/context/bank-api.ts | 48+++++++++++++++++++++++++++++-------------------
Mpackages/web-util/src/context/challenger-api.ts | 61+++++++++++++++++++++++++++++--------------------------------
Mpackages/web-util/src/context/exchange-api.ts | 61+++++++++++++++++++++++++++++++++++--------------------------
Mpackages/web-util/src/context/translation.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mpackages/web-util/src/hooks/useAsync.ts | 77+++++++++++++++++++++++++----------------------------------------------------
Mpackages/web-util/src/hooks/useAsyncAsHook.ts | 114++++++++++++++++++++++++-------------------------------------------------------
Mpackages/web-util/src/serve.ts | 66++++++++++++++++++++++++++++++++++++++----------------------------
Mpackages/web-util/src/tests/mock.ts | 11+++++------
Mpackages/web-util/src/utils/observable.ts | 58++++++++++++++++++++++++++++++++++++++--------------------
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;