@decocms/blocks 7.0.0
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 +97 -0
- package/src/cms/applySectionConventions.ts +128 -0
- package/src/cms/blockSource.test.ts +67 -0
- package/src/cms/blockSource.ts +124 -0
- package/src/cms/index.ts +104 -0
- package/src/cms/layoutCacheRace.test.ts +146 -0
- package/src/cms/loadDecofileDirectory.test.ts +52 -0
- package/src/cms/loadDecofileDirectory.ts +48 -0
- package/src/cms/loader.test.ts +184 -0
- package/src/cms/loader.ts +284 -0
- package/src/cms/registry.test.ts +118 -0
- package/src/cms/registry.ts +252 -0
- package/src/cms/resolve.test.ts +547 -0
- package/src/cms/resolve.ts +2003 -0
- package/src/cms/schema.ts +993 -0
- package/src/cms/sectionLoaders.test.ts +409 -0
- package/src/cms/sectionLoaders.ts +562 -0
- package/src/cms/sectionMixins.test.ts +163 -0
- package/src/cms/sectionMixins.ts +153 -0
- package/src/hooks/LazySection.tsx +121 -0
- package/src/hooks/LiveControls.tsx +122 -0
- package/src/hooks/RenderSection.test.tsx +81 -0
- package/src/hooks/RenderSection.tsx +91 -0
- package/src/hooks/SectionErrorFallback.tsx +97 -0
- package/src/hooks/index.ts +4 -0
- package/src/index.ts +8 -0
- package/src/matchers/builtins.test.ts +251 -0
- package/src/matchers/builtins.ts +437 -0
- package/src/matchers/countryNames.ts +104 -0
- package/src/matchers/override.test.ts +205 -0
- package/src/matchers/override.ts +136 -0
- package/src/matchers/posthog.ts +154 -0
- package/src/middleware/decoState.ts +55 -0
- package/src/middleware/healthMetrics.ts +133 -0
- package/src/middleware/hydrationContext.test.ts +61 -0
- package/src/middleware/hydrationContext.ts +79 -0
- package/src/middleware/index.ts +88 -0
- package/src/middleware/liveness.ts +21 -0
- package/src/middleware/observability.test.ts +238 -0
- package/src/middleware/observability.ts +620 -0
- package/src/middleware/validateSection.test.ts +147 -0
- package/src/middleware/validateSection.ts +100 -0
- package/src/sdk/abTesting.test.ts +326 -0
- package/src/sdk/abTesting.ts +499 -0
- package/src/sdk/analytics.ts +77 -0
- package/src/sdk/cacheHeaders.test.ts +115 -0
- package/src/sdk/cacheHeaders.ts +424 -0
- package/src/sdk/cachedLoader.ts +364 -0
- package/src/sdk/clx.ts +5 -0
- package/src/sdk/cn.test.ts +34 -0
- package/src/sdk/cn.ts +28 -0
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/cookie.test.ts +108 -0
- package/src/sdk/cookie.ts +129 -0
- package/src/sdk/crypto.ts +185 -0
- package/src/sdk/csp.ts +59 -0
- package/src/sdk/djb2.ts +20 -0
- package/src/sdk/encoding.test.ts +71 -0
- package/src/sdk/encoding.ts +47 -0
- package/src/sdk/env.ts +31 -0
- package/src/sdk/http.test.ts +71 -0
- package/src/sdk/http.ts +124 -0
- package/src/sdk/index.ts +90 -0
- package/src/sdk/inflightTimeout.test.ts +35 -0
- package/src/sdk/inflightTimeout.ts +53 -0
- package/src/sdk/instrumentedFetch.test.ts +559 -0
- package/src/sdk/instrumentedFetch.ts +339 -0
- package/src/sdk/invoke.test.ts +115 -0
- package/src/sdk/invoke.ts +260 -0
- package/src/sdk/logger.test.ts +432 -0
- package/src/sdk/logger.ts +304 -0
- package/src/sdk/mergeCacheControl.ts +150 -0
- package/src/sdk/normalizeUrls.ts +91 -0
- package/src/sdk/observability.ts +109 -0
- package/src/sdk/otel.test.ts +526 -0
- package/src/sdk/otel.ts +981 -0
- package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
- package/src/sdk/otelAdapters.test.ts +89 -0
- package/src/sdk/otelAdapters.ts +144 -0
- package/src/sdk/otelHttpLog.test.ts +457 -0
- package/src/sdk/otelHttpLog.ts +419 -0
- package/src/sdk/otelHttpMeter.test.ts +292 -0
- package/src/sdk/otelHttpMeter.ts +506 -0
- package/src/sdk/otelHttpTracer.test.ts +474 -0
- package/src/sdk/otelHttpTracer.ts +543 -0
- package/src/sdk/redirects.ts +225 -0
- package/src/sdk/requestContext.ts +281 -0
- package/src/sdk/requestContextStorage.browser.test.ts +29 -0
- package/src/sdk/requestContextStorage.browser.ts +43 -0
- package/src/sdk/requestContextStorage.ts +35 -0
- package/src/sdk/retry.ts +45 -0
- package/src/sdk/serverTimings.ts +68 -0
- package/src/sdk/signal.ts +42 -0
- package/src/sdk/sitemap.ts +160 -0
- package/src/sdk/urlRedaction.test.ts +73 -0
- package/src/sdk/urlRedaction.ts +82 -0
- package/src/sdk/urlUtils.ts +134 -0
- package/src/sdk/useDevice.test.ts +130 -0
- package/src/sdk/useDevice.ts +109 -0
- package/src/sdk/useDeviceContext.tsx +108 -0
- package/src/sdk/useId.ts +7 -0
- package/src/sdk/useScript.test.ts +128 -0
- package/src/sdk/useScript.ts +210 -0
- package/src/sdk/useSuggestions.test.ts +230 -0
- package/src/sdk/useSuggestions.ts +188 -0
- package/src/sdk/wrapCaughtErrors.ts +107 -0
- package/src/setup.ts +119 -0
- package/src/types/index.ts +39 -0
- package/src/types/widgets.ts +14 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { HttpError, STATUS_CODE, UserAgent } from "./http";
|
|
3
|
+
|
|
4
|
+
describe("STATUS_CODE", () => {
|
|
5
|
+
it("exposes common codes with the IANA-canonical names", () => {
|
|
6
|
+
expect(STATUS_CODE.OK).toBe(200);
|
|
7
|
+
expect(STATUS_CODE.MovedPermanently).toBe(301);
|
|
8
|
+
expect(STATUS_CODE.NotFound).toBe(404);
|
|
9
|
+
expect(STATUS_CODE.TooManyRequests).toBe(429);
|
|
10
|
+
expect(STATUS_CODE.InternalServerError).toBe(500);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("is readonly at the type level", () => {
|
|
14
|
+
// @ts-expect-error — assigning into the const map should not type-check.
|
|
15
|
+
STATUS_CODE.OK = 999;
|
|
16
|
+
// Even though the assignment is allowed at runtime (frozen-by-convention),
|
|
17
|
+
// we just want the type to flag it. The value is whatever JS allows.
|
|
18
|
+
expect(typeof STATUS_CODE.OK).toBe("number");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("UserAgent", () => {
|
|
23
|
+
it("accepts a string and exposes it via toString", () => {
|
|
24
|
+
const ua = new UserAgent("Mozilla/5.0 (test)");
|
|
25
|
+
expect(ua.toString()).toBe("Mozilla/5.0 (test)");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("treats null as an empty UA string", () => {
|
|
29
|
+
const ua = new UserAgent(null);
|
|
30
|
+
expect(ua.toString()).toBe("");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("exposes empty browser/os/cpu/device/engine accessors", () => {
|
|
34
|
+
const ua = new UserAgent("anything");
|
|
35
|
+
expect(ua.browser).toEqual({});
|
|
36
|
+
expect(ua.os).toEqual({});
|
|
37
|
+
expect(ua.cpu).toEqual({});
|
|
38
|
+
expect(ua.device).toEqual({});
|
|
39
|
+
expect(ua.engine).toEqual({});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("HttpError", () => {
|
|
44
|
+
it("captures status and message", () => {
|
|
45
|
+
const err = new HttpError(404, "Missing");
|
|
46
|
+
expect(err).toBeInstanceOf(Error);
|
|
47
|
+
expect(err.name).toBe("HttpError");
|
|
48
|
+
expect(err.status).toBe(404);
|
|
49
|
+
expect(err.message).toBe("Missing");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("defaults the message from the status when not provided", () => {
|
|
53
|
+
const err = new HttpError(503);
|
|
54
|
+
expect(err.message).toBe("HTTP 503");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("preserves an optional body payload for downstream handling", () => {
|
|
58
|
+
const body = { code: "rate_limited" };
|
|
59
|
+
const err = new HttpError(429, "Too Many Requests", body);
|
|
60
|
+
expect(err.body).toEqual(body);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("supports `instanceof` discrimination", () => {
|
|
64
|
+
const err = new HttpError(304);
|
|
65
|
+
function isNotModified(e: unknown): e is HttpError {
|
|
66
|
+
return e instanceof HttpError && e.status === 304;
|
|
67
|
+
}
|
|
68
|
+
expect(isNotModified(err)).toBe(true);
|
|
69
|
+
expect(isNotModified(new Error("nope"))).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/sdk/http.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP constants and small typed helpers — replaces the parts of Deno's
|
|
3
|
+
* `@std/http` (other than cookies, which live in `./cookie.ts`) that deco
|
|
4
|
+
* storefronts touch.
|
|
5
|
+
*
|
|
6
|
+
* Currently exposes:
|
|
7
|
+
* - `STATUS_CODE` — full IANA status-code map (parity with @std/http).
|
|
8
|
+
* - `UserAgent` — minimal class with the same shape; does not parse
|
|
9
|
+
* the UA string (sites only used `.toString()` and
|
|
10
|
+
* basic browser/os accessors in dev). Replace with a
|
|
11
|
+
* real parser like `ua-parser-js` if you actually
|
|
12
|
+
* depend on the parsed fields.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const STATUS_CODE = {
|
|
16
|
+
Continue: 100,
|
|
17
|
+
SwitchingProtocols: 101,
|
|
18
|
+
Processing: 102,
|
|
19
|
+
EarlyHints: 103,
|
|
20
|
+
OK: 200,
|
|
21
|
+
Created: 201,
|
|
22
|
+
Accepted: 202,
|
|
23
|
+
NonAuthoritativeInfo: 203,
|
|
24
|
+
NoContent: 204,
|
|
25
|
+
ResetContent: 205,
|
|
26
|
+
PartialContent: 206,
|
|
27
|
+
MultiStatus: 207,
|
|
28
|
+
AlreadyReported: 208,
|
|
29
|
+
IMUsed: 226,
|
|
30
|
+
MultipleChoices: 300,
|
|
31
|
+
MovedPermanently: 301,
|
|
32
|
+
Found: 302,
|
|
33
|
+
SeeOther: 303,
|
|
34
|
+
NotModified: 304,
|
|
35
|
+
UseProxy: 305,
|
|
36
|
+
TemporaryRedirect: 307,
|
|
37
|
+
PermanentRedirect: 308,
|
|
38
|
+
BadRequest: 400,
|
|
39
|
+
Unauthorized: 401,
|
|
40
|
+
PaymentRequired: 402,
|
|
41
|
+
Forbidden: 403,
|
|
42
|
+
NotFound: 404,
|
|
43
|
+
MethodNotAllowed: 405,
|
|
44
|
+
NotAcceptable: 406,
|
|
45
|
+
ProxyAuthRequired: 407,
|
|
46
|
+
RequestTimeout: 408,
|
|
47
|
+
Conflict: 409,
|
|
48
|
+
Gone: 410,
|
|
49
|
+
LengthRequired: 411,
|
|
50
|
+
PreconditionFailed: 412,
|
|
51
|
+
ContentTooLarge: 413,
|
|
52
|
+
URITooLong: 414,
|
|
53
|
+
UnsupportedMediaType: 415,
|
|
54
|
+
RangeNotSatisfiable: 416,
|
|
55
|
+
ExpectationFailed: 417,
|
|
56
|
+
Teapot: 418,
|
|
57
|
+
MisdirectedRequest: 421,
|
|
58
|
+
UnprocessableEntity: 422,
|
|
59
|
+
Locked: 423,
|
|
60
|
+
FailedDependency: 424,
|
|
61
|
+
TooEarly: 425,
|
|
62
|
+
UpgradeRequired: 426,
|
|
63
|
+
PreconditionRequired: 428,
|
|
64
|
+
TooManyRequests: 429,
|
|
65
|
+
RequestHeaderFieldsTooLarge: 431,
|
|
66
|
+
UnavailableForLegalReasons: 451,
|
|
67
|
+
InternalServerError: 500,
|
|
68
|
+
NotImplemented: 501,
|
|
69
|
+
BadGateway: 502,
|
|
70
|
+
ServiceUnavailable: 503,
|
|
71
|
+
GatewayTimeout: 504,
|
|
72
|
+
HTTPVersionNotSupported: 505,
|
|
73
|
+
VariantAlsoNegotiates: 506,
|
|
74
|
+
InsufficientStorage: 507,
|
|
75
|
+
LoopDetected: 508,
|
|
76
|
+
NotExtended: 510,
|
|
77
|
+
NetworkAuthenticationRequired: 511,
|
|
78
|
+
} as const;
|
|
79
|
+
|
|
80
|
+
export type StatusCode = typeof STATUS_CODE[keyof typeof STATUS_CODE];
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Minimal stand-in for Deno's `@std/http`'s `UserAgent`. Captures the raw
|
|
84
|
+
* UA string and exposes the same field shape; does NOT parse. Replace with
|
|
85
|
+
* a real parser when you start depending on parsed fields.
|
|
86
|
+
*/
|
|
87
|
+
export class UserAgent {
|
|
88
|
+
ua: string;
|
|
89
|
+
browser: { name?: string; version?: string };
|
|
90
|
+
os: { name?: string; version?: string };
|
|
91
|
+
device: { vendor?: string; model?: string; type?: string };
|
|
92
|
+
cpu: { architecture?: string };
|
|
93
|
+
engine: { name?: string; version?: string };
|
|
94
|
+
|
|
95
|
+
constructor(ua: string | null) {
|
|
96
|
+
this.ua = ua ?? "";
|
|
97
|
+
this.browser = {};
|
|
98
|
+
this.os = {};
|
|
99
|
+
this.device = {};
|
|
100
|
+
this.cpu = {};
|
|
101
|
+
this.engine = {};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
toString(): string {
|
|
105
|
+
return this.ua;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Lightweight HTTP error class. Drop-in for the `HttpError` shape that
|
|
111
|
+
* `deco-cx/apps` exposes — sites use it as `error instanceof HttpError &&
|
|
112
|
+
* error.status === 304` and similar.
|
|
113
|
+
*/
|
|
114
|
+
export class HttpError extends Error {
|
|
115
|
+
status: number;
|
|
116
|
+
body?: unknown;
|
|
117
|
+
|
|
118
|
+
constructor(status: number, message?: string, body?: unknown) {
|
|
119
|
+
super(message ?? `HTTP ${status}`);
|
|
120
|
+
this.name = "HttpError";
|
|
121
|
+
this.status = status;
|
|
122
|
+
this.body = body;
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/sdk/index.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export { ANALYTICS_SCRIPT, type DataEventParams, gtmScript, useSendEvent } from "./analytics";
|
|
2
|
+
export {
|
|
3
|
+
type CacheProfileConfig,
|
|
4
|
+
type CacheProfileName,
|
|
5
|
+
type CacheProfileOverrides,
|
|
6
|
+
type CacheTimingWindow,
|
|
7
|
+
cacheHeaders,
|
|
8
|
+
detectCacheProfile,
|
|
9
|
+
type EdgeCacheConfig,
|
|
10
|
+
edgeCacheConfig,
|
|
11
|
+
getCacheProfile,
|
|
12
|
+
type LoaderCacheOptions,
|
|
13
|
+
loaderCacheOptions,
|
|
14
|
+
registerCachePattern,
|
|
15
|
+
routeCacheDefaults,
|
|
16
|
+
setCacheProfile,
|
|
17
|
+
} from "./cacheHeaders";
|
|
18
|
+
export { clx } from "./clx";
|
|
19
|
+
export { decodeCookie, deleteCookie, getCookie, getServerSideCookie, setCookie } from "./cookie";
|
|
20
|
+
export { buildCSPHeaderValue, type CSPOptions, setCSPHeaders } from "./csp";
|
|
21
|
+
export { djb2, djb2Hex } from "./djb2";
|
|
22
|
+
export { isDevMode } from "./env";
|
|
23
|
+
export {
|
|
24
|
+
createInstrumentedFetch,
|
|
25
|
+
type FetchInstrumentationOptions,
|
|
26
|
+
type FetchMetrics,
|
|
27
|
+
instrumentFetch,
|
|
28
|
+
} from "./instrumentedFetch";
|
|
29
|
+
export {
|
|
30
|
+
batchInvoke,
|
|
31
|
+
createAppInvoke,
|
|
32
|
+
createInvokeProxy,
|
|
33
|
+
type InvokeProxy,
|
|
34
|
+
invoke,
|
|
35
|
+
invokeQueryOptions,
|
|
36
|
+
type NestedFromFlat,
|
|
37
|
+
} from "./invoke";
|
|
38
|
+
export { createCacheControlCollector, mergeCacheControl } from "./mergeCacheControl";
|
|
39
|
+
export {
|
|
40
|
+
getProductionOrigins,
|
|
41
|
+
normalizeUrlsInObject,
|
|
42
|
+
registerProductionOrigins,
|
|
43
|
+
} from "./normalizeUrls";
|
|
44
|
+
export {
|
|
45
|
+
addRedirects,
|
|
46
|
+
loadRedirects,
|
|
47
|
+
matchRedirect,
|
|
48
|
+
parseRedirectsCsv,
|
|
49
|
+
type Redirect,
|
|
50
|
+
type RedirectMap,
|
|
51
|
+
registerRedirectResolveType,
|
|
52
|
+
} from "./redirects";
|
|
53
|
+
export { createServerTimings, type ServerTimings } from "./serverTimings";
|
|
54
|
+
export { type ReactiveSignal, signal } from "./signal";
|
|
55
|
+
export { redactUrl, type RedactUrlOptions } from "./urlRedaction";
|
|
56
|
+
export {
|
|
57
|
+
canonicalUrl,
|
|
58
|
+
cleanPathForCacheKey,
|
|
59
|
+
hasTrackingParams,
|
|
60
|
+
registerTrackingParam,
|
|
61
|
+
registerTrackingParams,
|
|
62
|
+
stripTrackingParams,
|
|
63
|
+
} from "./urlUtils";
|
|
64
|
+
export {
|
|
65
|
+
checkDesktop,
|
|
66
|
+
checkMobile,
|
|
67
|
+
checkTablet,
|
|
68
|
+
type Device,
|
|
69
|
+
DeviceContext,
|
|
70
|
+
DeviceProvider,
|
|
71
|
+
detectDevice,
|
|
72
|
+
isMobileUA,
|
|
73
|
+
MOBILE_RE,
|
|
74
|
+
TABLET_RE,
|
|
75
|
+
useDevice,
|
|
76
|
+
} from "./useDevice";
|
|
77
|
+
export { useId } from "./useId";
|
|
78
|
+
export {
|
|
79
|
+
inlineScript,
|
|
80
|
+
usePartialSection,
|
|
81
|
+
useScript,
|
|
82
|
+
useScriptAsDataURI,
|
|
83
|
+
useSection,
|
|
84
|
+
} from "./useScript";
|
|
85
|
+
export {
|
|
86
|
+
isWrappedError,
|
|
87
|
+
unwrapError,
|
|
88
|
+
type WrappedError,
|
|
89
|
+
wrapCaughtErrors,
|
|
90
|
+
} from "./wrapCaughtErrors";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { DEFAULT_INFLIGHT_TIMEOUT_MS, withInflightTimeout } from "./inflightTimeout";
|
|
3
|
+
|
|
4
|
+
describe("withInflightTimeout", () => {
|
|
5
|
+
it("returns the underlying value when work settles in time", async () => {
|
|
6
|
+
const result = await withInflightTimeout(Promise.resolve(42), "ok-case");
|
|
7
|
+
expect(result).toBe(42);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("propagates rejection when underlying work rejects in time", async () => {
|
|
11
|
+
await expect(
|
|
12
|
+
withInflightTimeout(Promise.reject(new Error("boom")), "reject-case"),
|
|
13
|
+
).rejects.toThrow("boom");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("rejects with a timeout error when underlying work never settles", async () => {
|
|
17
|
+
vi.useFakeTimers();
|
|
18
|
+
try {
|
|
19
|
+
const hung = new Promise<number>(() => {});
|
|
20
|
+
const raced = withInflightTimeout(hung, "hung-case", 1_000);
|
|
21
|
+
// Swallow the eventual rejection so the runner doesn't see it as unhandled
|
|
22
|
+
raced.catch(() => {});
|
|
23
|
+
|
|
24
|
+
await vi.advanceTimersByTimeAsync(1_500);
|
|
25
|
+
await expect(raced).rejects.toThrow(/timed out/);
|
|
26
|
+
} finally {
|
|
27
|
+
vi.useRealTimers();
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("exports a sane default timeout", () => {
|
|
32
|
+
expect(DEFAULT_INFLIGHT_TIMEOUT_MS).toBeGreaterThanOrEqual(1_000);
|
|
33
|
+
expect(DEFAULT_INFLIGHT_TIMEOUT_MS).toBeLessThanOrEqual(60_000);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inflight-Promise timeout guard for module-level dedup caches.
|
|
3
|
+
*
|
|
4
|
+
* Background: across `@decocms/start` (and `@decocms/apps`) we keep
|
|
5
|
+
* `Map<string, Promise<...>>` caches at module scope to dedup concurrent
|
|
6
|
+
* resolutions of the same loader / section / layout block. Entries are
|
|
7
|
+
* evicted via `.finally()` on the stored Promise.
|
|
8
|
+
*
|
|
9
|
+
* The problem: if the wrapped work never settles — e.g. an upstream `fetch()`
|
|
10
|
+
* hangs because a CDN holds the TCP connection open — the `.finally()` never
|
|
11
|
+
* runs and the Map entry leaks forever. With
|
|
12
|
+
* `no_handle_cross_request_promise_resolution: true` on the consumer Worker,
|
|
13
|
+
* the zombie Promise survives across requests. Every subsequent caller for
|
|
14
|
+
* the same key `await`s it, pinning request context into memory until
|
|
15
|
+
* `exceededMemory` terminates the isolate.
|
|
16
|
+
*
|
|
17
|
+
* Production observation on a TanStack Start storefront (24h window): 514
|
|
18
|
+
* hard `exceededMemory` crashes, with CPU time ~0 and wall time in the tens
|
|
19
|
+
* of minutes — workers sleeping on the dead Promise, not computing.
|
|
20
|
+
*
|
|
21
|
+
* Fix: race the stored Promise against a timeout so its terminal state is
|
|
22
|
+
* guaranteed, which means `.finally()` always runs and the Map slot is
|
|
23
|
+
* freed. The underlying hung work is abandoned (the CF runtime will GC it
|
|
24
|
+
* once the request ends).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Default per-entry timeout for inflight dedup caches. */
|
|
28
|
+
export const DEFAULT_INFLIGHT_TIMEOUT_MS = 10_000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Race `work` against a timeout. If `work` doesn't settle within `ms`, the
|
|
32
|
+
* returned Promise rejects with a descriptive error so callers' `.finally()`
|
|
33
|
+
* cleanup always runs.
|
|
34
|
+
*/
|
|
35
|
+
export function withInflightTimeout<T>(
|
|
36
|
+
work: Promise<T>,
|
|
37
|
+
label: string,
|
|
38
|
+
ms: number = DEFAULT_INFLIGHT_TIMEOUT_MS,
|
|
39
|
+
): Promise<T> {
|
|
40
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
41
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
42
|
+
timer = setTimeout(() => {
|
|
43
|
+
reject(
|
|
44
|
+
new Error(
|
|
45
|
+
`[deco-start] inflight cache entry "${label}" timed out after ${ms}ms`,
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
}, ms);
|
|
49
|
+
});
|
|
50
|
+
return Promise.race([work, timeout]).finally(() => {
|
|
51
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
52
|
+
});
|
|
53
|
+
}
|