@decocms/blocks 7.37.2 → 7.37.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.37.2",
3
+ "version": "7.37.3",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -55,6 +55,7 @@
55
55
  "./sdk/encoding": "./src/sdk/encoding.ts",
56
56
  "./sdk/env": "./src/sdk/env.ts",
57
57
  "./sdk/fetchCache": "./src/sdk/fetchCache.ts",
58
+ "./sdk/fetchTimeout": "./src/sdk/fetchTimeout.ts",
58
59
  "./sdk/flags": "./src/sdk/flags.ts",
59
60
  "./sdk/http": "./src/sdk/http.ts",
60
61
  "./sdk/instrumentedFetch": "./src/sdk/instrumentedFetch.ts",
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Default network-level timeout for outbound `fetch()` calls.
3
+ *
4
+ * Background: nothing in `@decocms/blocks` previously bounded how long an
5
+ * outbound fetch could hang. `withInflightTimeout` (see `./inflightTimeout.ts`)
6
+ * only frees the module-level dedup Map slot when a wrapped Promise never
7
+ * settles — it abandons the underlying fetch rather than aborting it, so the
8
+ * TCP connection (and the isolate memory pinned to it) stays alive until the
9
+ * runtime kills the request. This module fixes the root cause: actually abort
10
+ * the request via `AbortSignal.timeout`, composed with any caller-supplied
11
+ * signal so callers that already do their own cancellation aren't overridden.
12
+ */
13
+
14
+ /** Default per-request timeout for outbound fetch calls. */
15
+ export const DEFAULT_FETCH_TIMEOUT_MS = 10_000;
16
+
17
+ /**
18
+ * Named alias for `typeof fetch`. Prefer this over repeating `typeof fetch`
19
+ * in signatures — packages that ban the bare `fetch` global (see this
20
+ * monorepo's `biome.json` `noRestrictedGlobals` override) flag `typeof fetch`
21
+ * too, since it's the same identifier reference; `FetchFn` sidesteps that.
22
+ */
23
+ export type FetchFn = typeof fetch;
24
+
25
+ /**
26
+ * Combine a caller-supplied `AbortSignal` (if any) with a timeout signal, so
27
+ * neither cancellation source is lost. Pass `timeoutMs <= 0` or
28
+ * non-finite to opt out of the timeout entirely (e.g. long-lived streaming
29
+ * requests) while still honoring the caller's own signal.
30
+ */
31
+ export function withTimeoutSignal(
32
+ signal: AbortSignal | undefined | null,
33
+ timeoutMs: number,
34
+ ): AbortSignal | undefined {
35
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return signal ?? undefined;
36
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
37
+ return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
38
+ }
39
+
40
+ /**
41
+ * Wrap a `fetch` implementation so every call is aborted after `timeoutMs`
42
+ * unless it settles first. Use directly at ad-hoc fetch call sites that
43
+ * don't go through `createInstrumentedFetch` (e.g. one-off API clients).
44
+ *
45
+ * When `baseFetch` is omitted, `globalThis.fetch` is resolved on every call
46
+ * (not captured once at wrap time) so tests that swap `globalThis.fetch`
47
+ * with a mock after this module loads still get intercepted.
48
+ */
49
+ export function withFetchTimeout(
50
+ baseFetch?: typeof fetch,
51
+ timeoutMs: number = DEFAULT_FETCH_TIMEOUT_MS,
52
+ ): typeof fetch {
53
+ return (input, init) =>
54
+ (baseFetch ?? globalThis.fetch)(input, {
55
+ ...init,
56
+ signal: withTimeoutSignal(init?.signal, timeoutMs),
57
+ });
58
+ }
package/src/sdk/index.ts CHANGED
@@ -20,6 +20,12 @@ export { decodeCookie, deleteCookie, getCookie, getServerSideCookie, setCookie }
20
20
  export { buildCSPHeaderValue, type CSPOptions, setCSPHeaders } from "./csp";
21
21
  export { djb2, djb2Hex } from "./djb2";
22
22
  export { isDevMode } from "./env";
23
+ export {
24
+ DEFAULT_FETCH_TIMEOUT_MS,
25
+ type FetchFn,
26
+ withFetchTimeout,
27
+ withTimeoutSignal,
28
+ } from "./fetchTimeout";
23
29
  export {
24
30
  createInstrumentedFetch,
25
31
  type FetchInstrumentationOptions,
@@ -15,6 +15,7 @@
15
15
  * ```
16
16
  */
17
17
 
18
+ import { DEFAULT_FETCH_TIMEOUT_MS, withTimeoutSignal } from "./fetchTimeout";
18
19
  import { logger } from "./logger";
19
20
  import { getTracer, injectTraceContext } from "./observability";
20
21
  import { redactUrl } from "./urlRedaction";
@@ -87,6 +88,15 @@ export interface FetchInstrumentationOptions {
87
88
  * `init.operation` ?? `defaultOperation` ?? `resolveOperation(url, method)` ?? `"fetch"`
88
89
  */
89
90
  resolveOperation?: (url: string, method: string) => string | undefined;
91
+ /**
92
+ * Abort a request after this many ms unless it settles first, via
93
+ * `AbortSignal.timeout` composed with any signal the caller already
94
+ * passed in `init.signal`. Default: {@link DEFAULT_FETCH_TIMEOUT_MS}
95
+ * (10s). Pass `0` or `Infinity` to disable for this integration (e.g. a
96
+ * client that intentionally makes long-lived/streaming requests).
97
+ * Override per-call via `init.timeoutMs`.
98
+ */
99
+ timeoutMs?: number;
90
100
  }
91
101
 
92
102
  export interface FetchMetrics {
@@ -115,6 +125,12 @@ export type InstrumentedFetchInit = RequestInit & {
115
125
  * surfaces to the network as a request property.
116
126
  */
117
127
  operation?: string;
128
+ /**
129
+ * Per-call timeout override (ms). See
130
+ * {@link FetchInstrumentationOptions.timeoutMs}. Stripped from the init
131
+ * before reaching `baseFetch`.
132
+ */
133
+ timeoutMs?: number;
118
134
  };
119
135
 
120
136
  /**
@@ -149,6 +165,7 @@ export function createInstrumentedFetch(
149
165
  injectTraceparent = true,
150
166
  defaultOperation,
151
167
  resolveOperation,
168
+ timeoutMs = DEFAULT_FETCH_TIMEOUT_MS,
152
169
  } = options;
153
170
 
154
171
  return async (input: RequestInfo | URL, init?: InstrumentedFetchInit): Promise<Response> => {
@@ -177,9 +194,10 @@ export function createInstrumentedFetch(
177
194
  // off init so it never reaches `baseFetch` as an unknown RequestInit
178
195
  // property (some runtimes warn / future runtimes might reject).
179
196
  const explicitOp = init?.operation;
197
+ const effectiveTimeoutMs = init?.timeoutMs ?? timeoutMs;
180
198
  let initForFetch: RequestInit | undefined = init;
181
- if (init && "operation" in init) {
182
- const { operation: _drop, ...rest } = init;
199
+ if (init && ("operation" in init || "timeoutMs" in init)) {
200
+ const { operation: _drop, timeoutMs: _drop2, ...rest } = init;
183
201
  initForFetch = rest;
184
202
  }
185
203
  const operation =
@@ -218,6 +236,11 @@ export function createInstrumentedFetch(
218
236
  finalInit = { ...(initForFetch ?? {}), headers };
219
237
  }
220
238
 
239
+ finalInit = {
240
+ ...(finalInit ?? {}),
241
+ signal: withTimeoutSignal(finalInit?.signal, effectiveTimeoutMs),
242
+ };
243
+
221
244
  const doFetch = async (): Promise<Response> => {
222
245
  if (logging) {
223
246
  console.log(`[${name}] ${method} ${truncateUrl(safeUrl)}`);