@decocms/blocks 7.37.1 → 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.
|
|
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",
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
8
8
|
import {
|
|
9
9
|
configureMeter,
|
|
10
|
+
normalizePath,
|
|
10
11
|
type MeterAdapter,
|
|
11
12
|
MetricNames,
|
|
12
13
|
recordCacheMetric,
|
|
@@ -256,3 +257,37 @@ describe("recordCommerceMetric (D-11)", () => {
|
|
|
256
257
|
).not.toThrow();
|
|
257
258
|
});
|
|
258
259
|
});
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* `http.route` is the highest-cardinality risk on the highest-volume metric.
|
|
263
|
+
* These cases are the real paths measured on production when the label reached
|
|
264
|
+
* 20,714 distinct values across 6 tenants.
|
|
265
|
+
*/
|
|
266
|
+
describe("normalizePath cardinality", () => {
|
|
267
|
+
it("collapses CMS content slugs", () => {
|
|
268
|
+
expect(normalizePath("/mochila-de-couro")).toBe("/:slug");
|
|
269
|
+
expect(normalizePath("/moveis/quarto-infantil")).toBe("/moveis/:slug");
|
|
270
|
+
expect(normalizePath("/granado/eau-de-toilette-spritz-100ml")).toBe("/granado/:slug");
|
|
271
|
+
expect(normalizePath("/moveis/quarto-adulto/cabeceiras-mesa-de-cabeceiras")).toBe(
|
|
272
|
+
"/moveis/:slug/:slug",
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("leaves bounded framework and API routes intact", () => {
|
|
277
|
+
expect(normalizePath("/")).toBe("/");
|
|
278
|
+
expect(normalizePath("/api/sessions")).toBe("/api/sessions");
|
|
279
|
+
expect(normalizePath("/deco/invoke/magento/loaders/features")).toBe(
|
|
280
|
+
"/deco/invoke/magento/loaders/features",
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("keeps dotfile segments readable", () => {
|
|
285
|
+
expect(normalizePath("/.well-known/passkey-endpoints")).toBe("/.well-known/:slug");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("still collapses ids and product pages", () => {
|
|
289
|
+
expect(normalizePath("/produto/12345")).toBe("/produto/:id");
|
|
290
|
+
expect(normalizePath("/a/deadbeefcafe")).toBe("/a/:id");
|
|
291
|
+
expect(normalizePath("/some-product/p")).toBe("/:slug/p");
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -611,10 +611,33 @@ export function recordLoaderError(name: string) {
|
|
|
611
611
|
*/
|
|
612
612
|
export function normalizePath(path: string): string {
|
|
613
613
|
// Collapse dynamic segments to reduce cardinality
|
|
614
|
-
|
|
614
|
+
const collapsed = path
|
|
615
615
|
.replace(/\/[0-9a-f]{8,}/gi, "/:id")
|
|
616
616
|
.replace(/\/\d+/g, "/:id")
|
|
617
617
|
.replace(/\/[^/]+\/p$/, "/:slug/p");
|
|
618
|
+
|
|
619
|
+
// Content slugs are the case none of the rules above catch: they carry no
|
|
620
|
+
// digits, no hex id and no `/p` suffix, so a CMS page passes through as its
|
|
621
|
+
// own literal path and becomes its own series.
|
|
622
|
+
//
|
|
623
|
+
// /mochila-de-couro
|
|
624
|
+
// /moveis/quarto-infantil
|
|
625
|
+
// /granado/eau-de-toilette-spritz-100ml
|
|
626
|
+
//
|
|
627
|
+
// Measured on the production ClickHouse, `http.route` reached 20,714
|
|
628
|
+
// distinct values across only 6 tenants — on `http.server.request.duration`,
|
|
629
|
+
// the highest-volume metric in the pipeline (19M rows / 3h).
|
|
630
|
+
//
|
|
631
|
+
// A hyphen is the discriminator. CMS slugs are hyphenated by construction,
|
|
632
|
+
// while framework and API segments are single words (`api`, `sessions`,
|
|
633
|
+
// `deco`, `invoke`, `loaders`, `render`). Dotfile segments such as
|
|
634
|
+
// `.well-known` are excluded so they keep their meaning.
|
|
635
|
+
return collapsed
|
|
636
|
+
.split("/")
|
|
637
|
+
.map((segment) =>
|
|
638
|
+
segment.includes("-") && !segment.startsWith(".") ? ":slug" : segment,
|
|
639
|
+
)
|
|
640
|
+
.join("/");
|
|
618
641
|
}
|
|
619
642
|
|
|
620
643
|
// ---------------------------------------------------------------------------
|
|
@@ -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)}`);
|