Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
}
}
}
}
]
}
6 changes: 5 additions & 1 deletion packages/apps-magento/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
* Magento has consistent muscle memory.
*/

import { withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout";

const timeoutFetch = withFetchTimeout();

// ---------------------------------------------------------------------------
// Config shapes
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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) });
}
28 changes: 16 additions & 12 deletions packages/apps-resend/src/__tests__/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <test@example.com>",
to: "user@example.com",
subject: "Hello",
html: "<p>World</p>",
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 <test@example.com>",
to: "user@example.com",
subject: "Hello",
html: "<p>World</p>",
}),
signal: expect.any(AbortSignal),
}),
});
);
});

it("falls back to defaults when fields are omitted", async () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/apps-resend/src/actions/send.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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}`,
Expand Down
5 changes: 3 additions & 2 deletions packages/apps-shopify/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { FetchFn } from "@decocms/blocks/sdk/fetchTimeout";
import { createGraphqlClient, type GraphQLClient } from "./utils/graphql";

export interface ShopifyConfig {
Expand All @@ -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.
Expand All @@ -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);
}
Expand Down
5 changes: 3 additions & 2 deletions packages/apps-shopify/src/utils/graphql.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -22,9 +23,9 @@ export interface GraphQLClient {
export function createGraphqlClient(
endpoint: string,
headers: Record<string, string>,
fetchFn?: typeof fetch,
fetchFn?: FetchFn,
): GraphQLClient {
const _fetch = fetchFn ?? globalThis.fetch;
const _fetch = fetchFn ?? withFetchTimeout();
return {
async query<T>(
queryOrDef: string | QueryDefinition,
Expand Down
3 changes: 2 additions & 1 deletion packages/apps-shopify/src/utils/instrumentedFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
* extractor returns `undefined`.
*/

import type { FetchFn } from "@decocms/blocks/sdk/fetchTimeout";
import {
createInstrumentedFetch,
type InstrumentedFetch,
Expand All @@ -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;
}

Expand Down
5 changes: 3 additions & 2 deletions packages/apps-vtex/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down
6 changes: 5 additions & 1 deletion packages/apps-vtex/src/utils/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
* the platform.
*/

import { withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout";

const timeoutFetch = withFetchTimeout();

type CachingMode = "stale-while-revalidate";

type DecoInit = {
Expand Down Expand Up @@ -88,7 +92,7 @@ export async function fetchSafe(
init?: DecoRequestInit,
): Promise<Response> {
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);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/apps-vtex/src/utils/instrumentedFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
* behavior.
*/

import type { FetchFn } from "@decocms/blocks/sdk/fetchTimeout";
import {
createInstrumentedFetch,
type InstrumentedFetch,
Expand All @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions packages/apps-vtex/src/utils/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<Response | null> => {
if (!isVtexSitemapPath(url.pathname)) return null;
Expand Down
7 changes: 5 additions & 2 deletions packages/apps-website/src/loaders/fonts/googleFonts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout";
import type { Font } from "../../types";

const timeoutFetch = withFetchTimeout();

interface Props {
fonts: GoogleFont[];
}
Expand Down Expand Up @@ -94,13 +97,13 @@ const loader = async (props: Props): Promise<Font> => {
};

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);
Expand Down
1 change: 1 addition & 0 deletions packages/blocks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
58 changes: 58 additions & 0 deletions packages/blocks/src/sdk/fetchTimeout.ts
Original file line number Diff line number Diff line change
@@ -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),
});
}
6 changes: 6 additions & 0 deletions packages/blocks/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading