diff --git a/biome.json b/biome.json index 46d052fe..d0433f3d 100644 --- a/biome.json +++ b/biome.json @@ -35,5 +35,33 @@ }, "files": { "includes": ["packages/*/src/**", "packages/*/scripts/**"] - } + }, + "overrides": [ + { + "includes": [ + "packages/apps-vtex/src/**", + "packages/apps-magento/src/**", + "packages/apps-resend/src/**", + "packages/apps-shopify/src/**", + "packages/apps-website/src/**", + "!packages/apps-vtex/src/hooks/**", + "!packages/*/src/**/*.test.ts", + "!packages/*/src/**/__tests__/**" + ], + "linter": { + "rules": { + "style": { + "noRestrictedGlobals": { + "level": "error", + "options": { + "deniedGlobals": { + "fetch": "Bare fetch() skips the request timeout. Use this package's timeout-wrapped client (e.g. fetchSafe/vtexFetch/magentoFetch/withFetchTimeout from @decocms/blocks/sdk/fetchTimeout) instead." + } + } + } + } + } + } + } + ] } diff --git a/packages/apps-magento/src/client.ts b/packages/apps-magento/src/client.ts index f8115926..55a44fac 100644 --- a/packages/apps-magento/src/client.ts +++ b/packages/apps-magento/src/client.ts @@ -15,6 +15,10 @@ * Magento has consistent muscle memory. */ +import { withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout"; + +const timeoutFetch = withFetchTimeout(); + // --------------------------------------------------------------------------- // Config shapes // --------------------------------------------------------------------------- @@ -224,5 +228,5 @@ export function magentoFetch(path: string, opts: MagentoFetchOpts = {}): Promise // clarity at the call site. const sameOrigin = target.origin === baseUrl.origin; - return fetch(target, { ...opts, headers: buildHeaders(opts, c, sameOrigin) }); + return timeoutFetch(target, { ...opts, headers: buildHeaders(opts, c, sameOrigin) }); } diff --git a/packages/apps-resend/src/__tests__/send.test.ts b/packages/apps-resend/src/__tests__/send.test.ts index b57637a7..e02c5ffb 100644 --- a/packages/apps-resend/src/__tests__/send.test.ts +++ b/packages/apps-resend/src/__tests__/send.test.ts @@ -35,19 +35,23 @@ describe("sendEmail", () => { expect(result.data).toEqual({ id: "email_abc123" }); expect(result.error).toBeNull(); - expect(fetchSpy).toHaveBeenCalledWith("https://api.resend.com/emails", { - method: "POST", - headers: { - Authorization: "Bearer re_test_123", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - from: "Test ", - to: "user@example.com", - subject: "Hello", - html: "

World

", + expect(fetchSpy).toHaveBeenCalledWith( + "https://api.resend.com/emails", + expect.objectContaining({ + method: "POST", + headers: { + Authorization: "Bearer re_test_123", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: "Test ", + to: "user@example.com", + subject: "Hello", + html: "

World

", + }), + signal: expect.any(AbortSignal), }), - }); + ); }); it("falls back to defaults when fields are omitted", async () => { diff --git a/packages/apps-resend/src/actions/send.ts b/packages/apps-resend/src/actions/send.ts index c2727253..8fbce651 100644 --- a/packages/apps-resend/src/actions/send.ts +++ b/packages/apps-resend/src/actions/send.ts @@ -1,6 +1,9 @@ +import { withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout"; import { getResendConfig } from "../client"; import type { CreateEmailOptions, CreateEmailResponse } from "../types"; +const timeoutFetch = withFetchTimeout(); + /** * Send an email via Resend API. * @@ -32,7 +35,7 @@ export async function sendEmail( ...(payload.headers && { headers: payload.headers }), }; - const response = await fetch("https://api.resend.com/emails", { + const response = await timeoutFetch("https://api.resend.com/emails", { method: "POST", headers: { Authorization: `Bearer ${config.apiKey}`, diff --git a/packages/apps-shopify/src/client.ts b/packages/apps-shopify/src/client.ts index 946ec8b3..f6f6ec18 100644 --- a/packages/apps-shopify/src/client.ts +++ b/packages/apps-shopify/src/client.ts @@ -1,3 +1,4 @@ +import type { FetchFn } from "@decocms/blocks/sdk/fetchTimeout"; import { createGraphqlClient, type GraphQLClient } from "./utils/graphql"; export interface ShopifyConfig { @@ -8,7 +9,7 @@ export interface ShopifyConfig { let _client: GraphQLClient | null = null; let _config: ShopifyConfig | null = null; -let _fetch: typeof fetch | undefined; +let _fetch: FetchFn | undefined; /** * Override the fetch function used by the Shopify GraphQL client. @@ -21,7 +22,7 @@ let _fetch: typeof fetch | undefined; * setShopifyFetch(createInstrumentedFetch("shopify")); * ``` */ -export function setShopifyFetch(fetchFn: typeof fetch) { +export function setShopifyFetch(fetchFn: FetchFn) { _fetch = fetchFn; if (_config) configureShopify(_config); } diff --git a/packages/apps-shopify/src/utils/graphql.ts b/packages/apps-shopify/src/utils/graphql.ts index d2be436f..49f378f7 100644 --- a/packages/apps-shopify/src/utils/graphql.ts +++ b/packages/apps-shopify/src/utils/graphql.ts @@ -1,3 +1,4 @@ +import { type FetchFn, withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout"; import type { InstrumentedFetchInit } from "@decocms/blocks/sdk/instrumentedFetch"; import { extractGraphqlOperationName } from "./graphqlOperationName"; @@ -22,9 +23,9 @@ export interface GraphQLClient { export function createGraphqlClient( endpoint: string, headers: Record, - fetchFn?: typeof fetch, + fetchFn?: FetchFn, ): GraphQLClient { - const _fetch = fetchFn ?? globalThis.fetch; + const _fetch = fetchFn ?? withFetchTimeout(); return { async query( queryOrDef: string | QueryDefinition, diff --git a/packages/apps-shopify/src/utils/instrumentedFetch.ts b/packages/apps-shopify/src/utils/instrumentedFetch.ts index 65715a0d..33fc6699 100644 --- a/packages/apps-shopify/src/utils/instrumentedFetch.ts +++ b/packages/apps-shopify/src/utils/instrumentedFetch.ts @@ -23,6 +23,7 @@ * extractor returns `undefined`. */ +import type { FetchFn } from "@decocms/blocks/sdk/fetchTimeout"; import { createInstrumentedFetch, type InstrumentedFetch, @@ -31,7 +32,7 @@ import { recordCommerceMetric } from "@decocms/blocks/sdk/observability"; import { shopifyOperationRouter } from "./operationRouter"; export interface CreateShopifyFetchOptions { - baseFetch?: typeof fetch; + baseFetch?: FetchFn; disableHistogram?: boolean; } diff --git a/packages/apps-vtex/src/client.ts b/packages/apps-vtex/src/client.ts index cc356c65..4fe35a44 100644 --- a/packages/apps-vtex/src/client.ts +++ b/packages/apps-vtex/src/client.ts @@ -3,6 +3,7 @@ * Uses VTEX's public REST APIs (Intelligent Search + Catalog + Checkout). */ +import { type FetchFn, withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout"; import type { InstrumentedFetch, InstrumentedFetchInit, @@ -147,7 +148,7 @@ export interface VtexConfig { } let _config: VtexConfig | null = null; -let _fetch: typeof fetch | InstrumentedFetch = globalThis.fetch; +let _fetch: FetchFn | InstrumentedFetch = withFetchTimeout(); export function configureVtex(config: VtexConfig) { _config = config; @@ -169,7 +170,7 @@ export function configureVtex(config: VtexConfig) { * uninstrumented (useful for tests + sites that haven't onboarded * the observability stack yet). */ -export function setVtexFetch(fetchFn: typeof fetch | InstrumentedFetch) { +export function setVtexFetch(fetchFn: FetchFn | InstrumentedFetch) { _fetch = fetchFn; } diff --git a/packages/apps-vtex/src/utils/fetch.ts b/packages/apps-vtex/src/utils/fetch.ts index 257cafa5..a4a96893 100644 --- a/packages/apps-vtex/src/utils/fetch.ts +++ b/packages/apps-vtex/src/utils/fetch.ts @@ -15,6 +15,10 @@ * the platform. */ +import { withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout"; + +const timeoutFetch = withFetchTimeout(); + type CachingMode = "stale-while-revalidate"; type DecoInit = { @@ -88,7 +92,7 @@ export async function fetchSafe( init?: DecoRequestInit, ): Promise { const sanitized = sanitizeUrl(input); - const response = await fetch(sanitized as RequestInfo, init); + const response = await timeoutFetch(sanitized as RequestInfo, init); if (!response.ok) { throw new HttpError(response); } diff --git a/packages/apps-vtex/src/utils/instrumentedFetch.ts b/packages/apps-vtex/src/utils/instrumentedFetch.ts index 7b0ee6e2..b461b471 100644 --- a/packages/apps-vtex/src/utils/instrumentedFetch.ts +++ b/packages/apps-vtex/src/utils/instrumentedFetch.ts @@ -30,6 +30,7 @@ * behavior. */ +import type { FetchFn } from "@decocms/blocks/sdk/fetchTimeout"; import { createInstrumentedFetch, type InstrumentedFetch, @@ -44,7 +45,7 @@ export interface CreateVtexFetchOptions { * or routes through a proxy) to preserve its behavior while adding * the VTEX instrumentation layer on top. */ - baseFetch?: typeof fetch; + baseFetch?: FetchFn; /** * Disable the `http.client.request.duration` histogram emission for * VTEX calls. The framework's span and structured logs still emit. diff --git a/packages/apps-vtex/src/utils/sitemap.ts b/packages/apps-vtex/src/utils/sitemap.ts index e4035c07..bd29a25f 100644 --- a/packages/apps-vtex/src/utils/sitemap.ts +++ b/packages/apps-vtex/src/utils/sitemap.ts @@ -11,6 +11,7 @@ * needs to expose VTEX's existing crawl tree to the public hostname. */ +import { type FetchFn, withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout"; import { getVtexConfig, vtexFetchResponse, vtexHost } from "../client"; export interface SitemapEntry { @@ -185,7 +186,7 @@ export interface VtexSitemapProxyConfig { * Optional fetch override — primarily for tests. Defaults to the * platform `fetch`. */ - fetchImpl?: typeof fetch; + fetchImpl?: FetchFn; } const DEFAULT_SITEMAP_CACHE_CONTROL = "public, s-maxage=3600, stale-while-revalidate=86400"; @@ -238,7 +239,7 @@ export function createVtexSitemapProxy( const environment = config.environment ?? "vtexcommercestable"; const cacheControl = config.cacheControl ?? DEFAULT_SITEMAP_CACHE_CONTROL; const extraSitemaps = config.extraSitemaps ?? []; - const fetchImpl = config.fetchImpl ?? fetch; + const fetchImpl = config.fetchImpl ?? withFetchTimeout(); return async (_request: Request, url: URL): Promise => { if (!isVtexSitemapPath(url.pathname)) return null; diff --git a/packages/apps-website/src/loaders/fonts/googleFonts.ts b/packages/apps-website/src/loaders/fonts/googleFonts.ts index bf134885..48af0bca 100644 --- a/packages/apps-website/src/loaders/fonts/googleFonts.ts +++ b/packages/apps-website/src/loaders/fonts/googleFonts.ts @@ -1,5 +1,8 @@ +import { withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout"; import type { Font } from "../../types"; +const timeoutFetch = withFetchTimeout(); + interface Props { fonts: GoogleFont[]; } @@ -94,13 +97,13 @@ const loader = async (props: Props): Promise => { }; const sheets = await Promise.all([ - fetch(url, { headers: OLD_BROWSER_KEY }) + timeoutFetch(url, { headers: OLD_BROWSER_KEY }) .then((res) => res.text()) .catch((e) => { logFontError("OLD_UA", url, e); return ""; }), - fetch(url, { headers: NEW_BROWSER_KEY }) + timeoutFetch(url, { headers: NEW_BROWSER_KEY }) .then((res) => res.text()) .catch((e) => { logFontError("NEW_UA", url, e); diff --git a/packages/blocks/package.json b/packages/blocks/package.json index d7d17c08..b5bfaa22 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -53,6 +53,7 @@ "./sdk/djb2": "./src/sdk/djb2.ts", "./sdk/encoding": "./src/sdk/encoding.ts", "./sdk/env": "./src/sdk/env.ts", + "./sdk/fetchTimeout": "./src/sdk/fetchTimeout.ts", "./sdk/flags": "./src/sdk/flags.ts", "./sdk/http": "./src/sdk/http.ts", "./sdk/instrumentedFetch": "./src/sdk/instrumentedFetch.ts", diff --git a/packages/blocks/src/sdk/fetchTimeout.ts b/packages/blocks/src/sdk/fetchTimeout.ts new file mode 100644 index 00000000..742ee185 --- /dev/null +++ b/packages/blocks/src/sdk/fetchTimeout.ts @@ -0,0 +1,58 @@ +/** + * Default network-level timeout for outbound `fetch()` calls. + * + * Background: nothing in `@decocms/blocks` previously bounded how long an + * outbound fetch could hang. `withInflightTimeout` (see `./inflightTimeout.ts`) + * only frees the module-level dedup Map slot when a wrapped Promise never + * settles — it abandons the underlying fetch rather than aborting it, so the + * TCP connection (and the isolate memory pinned to it) stays alive until the + * runtime kills the request. This module fixes the root cause: actually abort + * the request via `AbortSignal.timeout`, composed with any caller-supplied + * signal so callers that already do their own cancellation aren't overridden. + */ + +/** Default per-request timeout for outbound fetch calls. */ +export const DEFAULT_FETCH_TIMEOUT_MS = 10_000; + +/** + * Named alias for `typeof fetch`. Prefer this over repeating `typeof fetch` + * in signatures — packages that ban the bare `fetch` global (see this + * monorepo's `biome.json` `noRestrictedGlobals` override) flag `typeof fetch` + * too, since it's the same identifier reference; `FetchFn` sidesteps that. + */ +export type FetchFn = typeof fetch; + +/** + * Combine a caller-supplied `AbortSignal` (if any) with a timeout signal, so + * neither cancellation source is lost. Pass `timeoutMs <= 0` or + * non-finite to opt out of the timeout entirely (e.g. long-lived streaming + * requests) while still honoring the caller's own signal. + */ +export function withTimeoutSignal( + signal: AbortSignal | undefined | null, + timeoutMs: number, +): AbortSignal | undefined { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return signal ?? undefined; + const timeoutSignal = AbortSignal.timeout(timeoutMs); + return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; +} + +/** + * Wrap a `fetch` implementation so every call is aborted after `timeoutMs` + * unless it settles first. Use directly at ad-hoc fetch call sites that + * don't go through `createInstrumentedFetch` (e.g. one-off API clients). + * + * When `baseFetch` is omitted, `globalThis.fetch` is resolved on every call + * (not captured once at wrap time) so tests that swap `globalThis.fetch` + * with a mock after this module loads still get intercepted. + */ +export function withFetchTimeout( + baseFetch?: typeof fetch, + timeoutMs: number = DEFAULT_FETCH_TIMEOUT_MS, +): typeof fetch { + return (input, init) => + (baseFetch ?? globalThis.fetch)(input, { + ...init, + signal: withTimeoutSignal(init?.signal, timeoutMs), + }); +} diff --git a/packages/blocks/src/sdk/index.ts b/packages/blocks/src/sdk/index.ts index 1688c767..05551116 100644 --- a/packages/blocks/src/sdk/index.ts +++ b/packages/blocks/src/sdk/index.ts @@ -20,6 +20,12 @@ export { decodeCookie, deleteCookie, getCookie, getServerSideCookie, setCookie } export { buildCSPHeaderValue, type CSPOptions, setCSPHeaders } from "./csp"; export { djb2, djb2Hex } from "./djb2"; export { isDevMode } from "./env"; +export { + DEFAULT_FETCH_TIMEOUT_MS, + type FetchFn, + withFetchTimeout, + withTimeoutSignal, +} from "./fetchTimeout"; export { createInstrumentedFetch, type FetchInstrumentationOptions, diff --git a/packages/blocks/src/sdk/instrumentedFetch.ts b/packages/blocks/src/sdk/instrumentedFetch.ts index 0d4caf88..01aa2dbd 100644 --- a/packages/blocks/src/sdk/instrumentedFetch.ts +++ b/packages/blocks/src/sdk/instrumentedFetch.ts @@ -15,6 +15,7 @@ * ``` */ +import { DEFAULT_FETCH_TIMEOUT_MS, withTimeoutSignal } from "./fetchTimeout"; import { logger } from "./logger"; import { getTracer, injectTraceContext } from "./observability"; import { redactUrl } from "./urlRedaction"; @@ -87,6 +88,15 @@ export interface FetchInstrumentationOptions { * `init.operation` ?? `defaultOperation` ?? `resolveOperation(url, method)` ?? `"fetch"` */ resolveOperation?: (url: string, method: string) => string | undefined; + /** + * Abort a request after this many ms unless it settles first, via + * `AbortSignal.timeout` composed with any signal the caller already + * passed in `init.signal`. Default: {@link DEFAULT_FETCH_TIMEOUT_MS} + * (10s). Pass `0` or `Infinity` to disable for this integration (e.g. a + * client that intentionally makes long-lived/streaming requests). + * Override per-call via `init.timeoutMs`. + */ + timeoutMs?: number; } export interface FetchMetrics { @@ -115,6 +125,12 @@ export type InstrumentedFetchInit = RequestInit & { * surfaces to the network as a request property. */ operation?: string; + /** + * Per-call timeout override (ms). See + * {@link FetchInstrumentationOptions.timeoutMs}. Stripped from the init + * before reaching `baseFetch`. + */ + timeoutMs?: number; }; /** @@ -149,6 +165,7 @@ export function createInstrumentedFetch( injectTraceparent = true, defaultOperation, resolveOperation, + timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, } = options; return async (input: RequestInfo | URL, init?: InstrumentedFetchInit): Promise => { @@ -177,9 +194,10 @@ export function createInstrumentedFetch( // off init so it never reaches `baseFetch` as an unknown RequestInit // property (some runtimes warn / future runtimes might reject). const explicitOp = init?.operation; + const effectiveTimeoutMs = init?.timeoutMs ?? timeoutMs; let initForFetch: RequestInit | undefined = init; - if (init && "operation" in init) { - const { operation: _drop, ...rest } = init; + if (init && ("operation" in init || "timeoutMs" in init)) { + const { operation: _drop, timeoutMs: _drop2, ...rest } = init; initForFetch = rest; } const operation = @@ -218,6 +236,11 @@ export function createInstrumentedFetch( finalInit = { ...(initForFetch ?? {}), headers }; } + finalInit = { + ...(finalInit ?? {}), + signal: withTimeoutSignal(finalInit?.signal, effectiveTimeoutMs), + }; + const doFetch = async (): Promise => { if (logging) { console.log(`[${name}] ${method} ${truncateUrl(safeUrl)}`);