@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,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMS-managed redirect system.
|
|
3
|
+
*
|
|
4
|
+
* Loads redirect definitions from .deco/blocks/ and provides
|
|
5
|
+
* fast path matching for use in TanStack Start middleware.
|
|
6
|
+
*
|
|
7
|
+
* Supports:
|
|
8
|
+
* - Exact path matches (/old-page -> /new-page)
|
|
9
|
+
* - Glob patterns (/old/* -> /new/*)
|
|
10
|
+
* - Permanent (301) and temporary (302) redirects
|
|
11
|
+
* - CSV import for bulk redirects
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* // In TanStack Start middleware
|
|
16
|
+
* import { loadRedirects, matchRedirect } from "@decocms/start/sdk/redirects";
|
|
17
|
+
* import { loadBlocks } from "@decocms/start/cms";
|
|
18
|
+
*
|
|
19
|
+
* const redirects = loadRedirects(loadBlocks());
|
|
20
|
+
*
|
|
21
|
+
* const middleware = createMiddleware().server(async ({ next, request }) => {
|
|
22
|
+
* const url = new URL(request.url);
|
|
23
|
+
* const redirect = matchRedirect(url.pathname, redirects);
|
|
24
|
+
* if (redirect) {
|
|
25
|
+
* return new Response(null, {
|
|
26
|
+
* status: redirect.status,
|
|
27
|
+
* headers: { Location: redirect.to },
|
|
28
|
+
* });
|
|
29
|
+
* }
|
|
30
|
+
* return next();
|
|
31
|
+
* });
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
// -------------------------------------------------------------------------
|
|
36
|
+
// Types
|
|
37
|
+
// -------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
export interface Redirect {
|
|
40
|
+
from: string;
|
|
41
|
+
to: string;
|
|
42
|
+
status: 301 | 302;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RedirectMap {
|
|
46
|
+
/** Exact match redirects for O(1) lookup. */
|
|
47
|
+
exact: Map<string, Redirect>;
|
|
48
|
+
/** Glob/prefix redirects checked sequentially (few in practice). */
|
|
49
|
+
patterns: Array<{ prefix: string; redirect: Redirect }>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// -------------------------------------------------------------------------
|
|
53
|
+
// Loading from CMS blocks
|
|
54
|
+
// -------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
interface BlockRedirectEntry {
|
|
57
|
+
from: string;
|
|
58
|
+
to: string;
|
|
59
|
+
type?: "permanent" | "temporary";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const REDIRECT_RESOLVE_TYPES = new Set([
|
|
63
|
+
"website/loaders/redirect.ts",
|
|
64
|
+
"website/loaders/redirects.ts",
|
|
65
|
+
"website/loaders/redirectsFromCsv.ts",
|
|
66
|
+
"deco-sites/std/loaders/x/redirects.ts",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Register additional __resolveType strings that should be treated as redirect blocks.
|
|
71
|
+
* Useful for custom redirect loaders.
|
|
72
|
+
*/
|
|
73
|
+
export function registerRedirectResolveType(resolveType: string): void {
|
|
74
|
+
REDIRECT_RESOLVE_TYPES.add(resolveType);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Load all redirect definitions from CMS blocks.
|
|
79
|
+
*
|
|
80
|
+
* Scans the blocks for known redirect resolve types and builds
|
|
81
|
+
* a fast-lookup redirect map.
|
|
82
|
+
*/
|
|
83
|
+
export function loadRedirects(blocks: Record<string, unknown>): RedirectMap {
|
|
84
|
+
const exact = new Map<string, Redirect>();
|
|
85
|
+
const patterns: Array<{ prefix: string; redirect: Redirect }> = [];
|
|
86
|
+
|
|
87
|
+
for (const [_key, block] of Object.entries(blocks)) {
|
|
88
|
+
if (!block || typeof block !== "object") continue;
|
|
89
|
+
const obj = block as Record<string, unknown>;
|
|
90
|
+
const resolveType = obj.__resolveType as string | undefined;
|
|
91
|
+
|
|
92
|
+
if (!resolveType || !REDIRECT_RESOLVE_TYPES.has(resolveType)) continue;
|
|
93
|
+
|
|
94
|
+
const entries = (obj.redirects ?? obj.redirect) as
|
|
95
|
+
| BlockRedirectEntry[]
|
|
96
|
+
| BlockRedirectEntry
|
|
97
|
+
| undefined;
|
|
98
|
+
|
|
99
|
+
if (!entries) continue;
|
|
100
|
+
|
|
101
|
+
const list = Array.isArray(entries) ? entries : [entries];
|
|
102
|
+
|
|
103
|
+
for (const entry of list) {
|
|
104
|
+
if (!entry.from || !entry.to) continue;
|
|
105
|
+
|
|
106
|
+
const redirect: Redirect = {
|
|
107
|
+
from: normalizePath(entry.from),
|
|
108
|
+
to: entry.to,
|
|
109
|
+
status: entry.type === "permanent" ? 301 : 302,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
if (redirect.from.includes("*")) {
|
|
113
|
+
const prefix = redirect.from.replace(/\*+$/, "");
|
|
114
|
+
patterns.push({ prefix, redirect });
|
|
115
|
+
} else {
|
|
116
|
+
exact.set(redirect.from, redirect);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { exact, patterns };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// -------------------------------------------------------------------------
|
|
125
|
+
// CSV import
|
|
126
|
+
// -------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parse a CSV string into redirect entries.
|
|
130
|
+
*
|
|
131
|
+
* Expected format: `from,to[,type]` (one per line).
|
|
132
|
+
* Lines starting with # are comments. Empty lines are skipped.
|
|
133
|
+
* Type is "permanent" (301) or "temporary" (302, default).
|
|
134
|
+
*/
|
|
135
|
+
export function parseRedirectsCsv(csv: string): Redirect[] {
|
|
136
|
+
const redirects: Redirect[] = [];
|
|
137
|
+
const lines = csv.split("\n");
|
|
138
|
+
|
|
139
|
+
for (const raw of lines) {
|
|
140
|
+
const line = raw.trim();
|
|
141
|
+
if (!line || line.startsWith("#")) continue;
|
|
142
|
+
|
|
143
|
+
const parts = line.split(",").map((p) => p.trim());
|
|
144
|
+
if (parts.length < 2) continue;
|
|
145
|
+
|
|
146
|
+
const [from, to, type] = parts;
|
|
147
|
+
if (!from || !to) continue;
|
|
148
|
+
|
|
149
|
+
redirects.push({
|
|
150
|
+
from: normalizePath(from),
|
|
151
|
+
to,
|
|
152
|
+
status: type === "permanent" || type === "301" ? 301 : 302,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return redirects;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Add parsed redirects to an existing redirect map.
|
|
161
|
+
*/
|
|
162
|
+
export function addRedirects(map: RedirectMap, redirects: Redirect[]): void {
|
|
163
|
+
for (const redirect of redirects) {
|
|
164
|
+
if (redirect.from.includes("*")) {
|
|
165
|
+
const prefix = redirect.from.replace(/\*+$/, "");
|
|
166
|
+
map.patterns.push({ prefix, redirect });
|
|
167
|
+
} else {
|
|
168
|
+
map.exact.set(redirect.from, redirect);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// -------------------------------------------------------------------------
|
|
174
|
+
// Matching
|
|
175
|
+
// -------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Find a redirect matching the given path.
|
|
179
|
+
*
|
|
180
|
+
* Checks exact matches first (O(1)), then glob patterns (O(n), but
|
|
181
|
+
* typically few patterns exist).
|
|
182
|
+
*/
|
|
183
|
+
export function matchRedirect(pathname: string, map: RedirectMap): Redirect | null {
|
|
184
|
+
const normalized = normalizePath(pathname);
|
|
185
|
+
|
|
186
|
+
const exactMatch = map.exact.get(normalized);
|
|
187
|
+
if (exactMatch) return exactMatch;
|
|
188
|
+
|
|
189
|
+
for (const { prefix, redirect } of map.patterns) {
|
|
190
|
+
if (normalized.startsWith(prefix)) {
|
|
191
|
+
const suffix = normalized.slice(prefix.length);
|
|
192
|
+
const to = redirect.to.includes("*") ? redirect.to.replace("*", suffix) : redirect.to;
|
|
193
|
+
return { ...redirect, to };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// -------------------------------------------------------------------------
|
|
201
|
+
// Helpers
|
|
202
|
+
// -------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
function normalizePath(path: string): string {
|
|
205
|
+
let p = path.trim();
|
|
206
|
+
|
|
207
|
+
// If the "from" is a full URL, extract just the pathname
|
|
208
|
+
if (p.startsWith("http://") || p.startsWith("https://")) {
|
|
209
|
+
try {
|
|
210
|
+
p = new URL(p).pathname;
|
|
211
|
+
} catch {
|
|
212
|
+
// malformed URL, keep as-is and try the prefix fallback
|
|
213
|
+
const slashIdx = p.indexOf("/", p.indexOf("//") + 2);
|
|
214
|
+
p = slashIdx >= 0 ? p.slice(slashIdx) : p;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!p.startsWith("/")) {
|
|
219
|
+
p = "/" + p;
|
|
220
|
+
}
|
|
221
|
+
if (p.length > 1 && p.endsWith("/")) {
|
|
222
|
+
p = p.slice(0, -1);
|
|
223
|
+
}
|
|
224
|
+
return p.toLowerCase();
|
|
225
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request context via AsyncLocalStorage.
|
|
3
|
+
*
|
|
4
|
+
* Binds request-scoped state (request, abort signal, device info, flags)
|
|
5
|
+
* that any code in the call stack can access without prop drilling.
|
|
6
|
+
*
|
|
7
|
+
* Requires `nodejs_compat` in wrangler.jsonc (already enabled).
|
|
8
|
+
*
|
|
9
|
+
* **Design decisions:**
|
|
10
|
+
* - We do NOT monkey-patch global `fetch`. Instead, `RequestContext.fetch`
|
|
11
|
+
* provides a fetch that auto-injects the request's AbortSignal.
|
|
12
|
+
* - The context is optional -- code that doesn't need it just doesn't call it.
|
|
13
|
+
* Commerce loaders receive it explicitly via the updated `CommerceLoader` sig.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* // In TanStack Start middleware:
|
|
18
|
+
* import { RequestContext } from "@decocms/start/sdk/requestContext";
|
|
19
|
+
*
|
|
20
|
+
* const middleware = createMiddleware().server(async ({ next, request }) => {
|
|
21
|
+
* return RequestContext.run(request, () => next());
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* // Anywhere in the call stack:
|
|
25
|
+
* const req = RequestContext.request; // the current request
|
|
26
|
+
* const signal = RequestContext.signal; // AbortSignal
|
|
27
|
+
* const resp = await RequestContext.fetch(url); // auto-aborts on disconnect
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// The `node:async_hooks`-backed storage lives in a sibling module so that
|
|
32
|
+
// bundlers targeting the browser can swap it for a no-op stub via the
|
|
33
|
+
// `"browser"` export condition (see `./requestContextStorage` and the
|
|
34
|
+
// `./sdk/requestContextStorage` entry in `package.json`). We import it through
|
|
35
|
+
// the package's own name — NOT a relative path — because export conditions are
|
|
36
|
+
// only consulted at package-specifier boundaries; a relative `./requestContextStorage`
|
|
37
|
+
// would always resolve straight to the Node file and drag `node:async_hooks`
|
|
38
|
+
// into the client graph, which is exactly the `UnhandledSchemeError` we're
|
|
39
|
+
// avoiding. Node/Vitest resolve the `default`/`node` condition → real storage;
|
|
40
|
+
// Next.js's client webpack compiler resolves `browser` → the stub.
|
|
41
|
+
import { storage } from "@decocms/blocks/sdk/requestContextStorage";
|
|
42
|
+
|
|
43
|
+
// -------------------------------------------------------------------------
|
|
44
|
+
// Types
|
|
45
|
+
// -------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
export interface RequestContextData {
|
|
48
|
+
request: Request;
|
|
49
|
+
signal: AbortSignal;
|
|
50
|
+
startedAt: number;
|
|
51
|
+
/**
|
|
52
|
+
* Stable per-request identifier. Generated by `workerEntry.ts` from
|
|
53
|
+
* `cf-ray` when present, falling back to `crypto.randomUUID()`. Echoed
|
|
54
|
+
* back to the client as the `X-Request-Id` response header so:
|
|
55
|
+
*
|
|
56
|
+
* - The producer Worker can stamp it on every log / span / metric
|
|
57
|
+
* attribute (`request.id`) for join queries.
|
|
58
|
+
* - The CF tail worker reads it from the response header and stamps
|
|
59
|
+
* the same `request.id` on tail rows — restores the join key on a
|
|
60
|
+
* channel that has no other shared identity with direct-POST.
|
|
61
|
+
* - Clients can include it in support requests so we can pull the
|
|
62
|
+
* full trace + logs from one ID.
|
|
63
|
+
*
|
|
64
|
+
* See `MIGRATION_TOOLING_PLAN.md` D-9 for the propagation policy.
|
|
65
|
+
*/
|
|
66
|
+
requestId: string;
|
|
67
|
+
/** Lazily computed device type. */
|
|
68
|
+
_device?: "mobile" | "desktop";
|
|
69
|
+
/** Lazily computed bot detection result. */
|
|
70
|
+
_isBot?: boolean;
|
|
71
|
+
/** Arbitrary bag for middleware to attach custom data. */
|
|
72
|
+
bag: Map<string, unknown>;
|
|
73
|
+
/**
|
|
74
|
+
* Outgoing response headers that handlers can write to.
|
|
75
|
+
* Invoke handlers (actions/loaders) use this to forward Set-Cookie
|
|
76
|
+
* and other headers from upstream APIs (e.g., VTEX checkout).
|
|
77
|
+
* The invoke HTTP handler copies these into the final Response.
|
|
78
|
+
*
|
|
79
|
+
* This mirrors deco-cx/deco's `ctx.response.headers` pattern where
|
|
80
|
+
* `proxySetCookie(apiResponse.headers, ctx.response.headers)` forwards
|
|
81
|
+
* cookies transparently.
|
|
82
|
+
*/
|
|
83
|
+
responseHeaders: Headers;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// -------------------------------------------------------------------------
|
|
87
|
+
// Storage
|
|
88
|
+
// -------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
import { isMobileUA } from "./useDevice";
|
|
91
|
+
|
|
92
|
+
const BOT_RE =
|
|
93
|
+
/bot|crawl|spider|slurp|bingpreview|facebookexternalhit|linkedinbot|twitterbot|whatsapp|telegram|googlebot|yandex|baidu|duckduck/i;
|
|
94
|
+
|
|
95
|
+
// -------------------------------------------------------------------------
|
|
96
|
+
// Public API
|
|
97
|
+
// -------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
export const RequestContext = {
|
|
100
|
+
/**
|
|
101
|
+
* Run a function within a request context.
|
|
102
|
+
*
|
|
103
|
+
* Call this at the outermost middleware level. Everything inside
|
|
104
|
+
* the callback (loaders, resolvers, utilities) can access the
|
|
105
|
+
* context via the static getters.
|
|
106
|
+
*/
|
|
107
|
+
run<T>(request: Request, fn: () => T): T {
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
|
|
110
|
+
if (request.signal) {
|
|
111
|
+
if (request.signal.aborted) {
|
|
112
|
+
controller.abort(request.signal.reason);
|
|
113
|
+
} else {
|
|
114
|
+
request.signal.addEventListener("abort", () => controller.abort(request.signal.reason), {
|
|
115
|
+
once: true,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Resolve request.id with explicit precedence so the choice is
|
|
121
|
+
// auditable from a single read:
|
|
122
|
+
// (1) inbound `x-request-id` — caller-supplied (e.g. a load balancer
|
|
123
|
+
// already tagged the request, or a synthetic test passed one in)
|
|
124
|
+
// (2) `cf-ray` — Cloudflare's own per-request identifier, present
|
|
125
|
+
// on every real Workers request and globally unique
|
|
126
|
+
// (3) `crypto.randomUUID()` — last resort for tests and locally-run
|
|
127
|
+
// dev without `cf-ray`
|
|
128
|
+
const incomingReqId = request.headers.get("x-request-id");
|
|
129
|
+
const cfRay = request.headers.get("cf-ray");
|
|
130
|
+
const requestId =
|
|
131
|
+
incomingReqId && incomingReqId.length > 0
|
|
132
|
+
? incomingReqId
|
|
133
|
+
: cfRay && cfRay.length > 0
|
|
134
|
+
? cfRay
|
|
135
|
+
: (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
|
136
|
+
? crypto.randomUUID()
|
|
137
|
+
: `${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
138
|
+
|
|
139
|
+
const ctx: RequestContextData = {
|
|
140
|
+
request,
|
|
141
|
+
signal: controller.signal,
|
|
142
|
+
startedAt: Date.now(),
|
|
143
|
+
requestId,
|
|
144
|
+
bag: new Map(),
|
|
145
|
+
responseHeaders: new Headers(),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return storage.run(ctx, fn);
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get the current request context, or null if not in a request scope.
|
|
153
|
+
*/
|
|
154
|
+
get current(): RequestContextData | null {
|
|
155
|
+
return storage.getStore() ?? null;
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Get the current Request object.
|
|
160
|
+
* @throws if called outside a request context
|
|
161
|
+
*/
|
|
162
|
+
get request(): Request {
|
|
163
|
+
const ctx = storage.getStore();
|
|
164
|
+
if (!ctx) throw new Error("RequestContext.request accessed outside a request scope");
|
|
165
|
+
return ctx.request;
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Get the current AbortSignal.
|
|
170
|
+
* Use this to cancel in-flight operations when the client disconnects.
|
|
171
|
+
*/
|
|
172
|
+
get signal(): AbortSignal {
|
|
173
|
+
const ctx = storage.getStore();
|
|
174
|
+
if (!ctx) throw new Error("RequestContext.signal accessed outside a request scope");
|
|
175
|
+
return ctx.signal;
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The stable per-request identifier — see `RequestContextData.requestId`
|
|
180
|
+
* for the full propagation policy. Returns `null` when called outside a
|
|
181
|
+
* request scope (vs throwing, because observability helpers in the
|
|
182
|
+
* shutdown path of a Worker may pull it lazily).
|
|
183
|
+
*/
|
|
184
|
+
get requestId(): string | null {
|
|
185
|
+
return storage.getStore()?.requestId ?? null;
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Detected device type based on User-Agent.
|
|
190
|
+
*/
|
|
191
|
+
get device(): "mobile" | "desktop" {
|
|
192
|
+
const ctx = storage.getStore();
|
|
193
|
+
if (!ctx) return "desktop";
|
|
194
|
+
if (ctx._device) return ctx._device;
|
|
195
|
+
const ua = ctx.request.headers.get("user-agent") ?? "";
|
|
196
|
+
ctx._device = isMobileUA(ua) ? "mobile" : "desktop";
|
|
197
|
+
return ctx._device;
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Whether the request appears to be from a bot/crawler.
|
|
202
|
+
*/
|
|
203
|
+
get isBot(): boolean {
|
|
204
|
+
const ctx = storage.getStore();
|
|
205
|
+
if (!ctx) return false;
|
|
206
|
+
if (ctx._isBot !== undefined) return ctx._isBot;
|
|
207
|
+
const ua = ctx.request.headers.get("user-agent") ?? "";
|
|
208
|
+
ctx._isBot = BOT_RE.test(ua);
|
|
209
|
+
return ctx._isBot;
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Elapsed time since the request started (in milliseconds).
|
|
214
|
+
*/
|
|
215
|
+
get elapsed(): number {
|
|
216
|
+
const ctx = storage.getStore();
|
|
217
|
+
if (!ctx) return 0;
|
|
218
|
+
return Date.now() - ctx.startedAt;
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Fetch with automatic AbortSignal injection.
|
|
223
|
+
*
|
|
224
|
+
* When the client disconnects, this fetch aborts automatically.
|
|
225
|
+
* This is NOT a global monkey-patch -- only code that explicitly
|
|
226
|
+
* calls `RequestContext.fetch()` gets this behavior.
|
|
227
|
+
*/
|
|
228
|
+
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
229
|
+
const ctx = storage.getStore();
|
|
230
|
+
if (!ctx) return globalThis.fetch(input, init);
|
|
231
|
+
|
|
232
|
+
return globalThis.fetch(input, {
|
|
233
|
+
...init,
|
|
234
|
+
signal: init?.signal ?? ctx.signal,
|
|
235
|
+
});
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Outgoing response headers. Handlers write here; the invoke endpoint
|
|
240
|
+
* copies them into the HTTP Response (mirroring ctx.response.headers
|
|
241
|
+
* from deco-cx/deco).
|
|
242
|
+
*/
|
|
243
|
+
get responseHeaders(): Headers {
|
|
244
|
+
const ctx = storage.getStore();
|
|
245
|
+
if (!ctx) throw new Error("RequestContext.responseHeaders accessed outside a request scope");
|
|
246
|
+
return ctx.responseHeaders;
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Get/set arbitrary values in the request bag.
|
|
251
|
+
* Useful for middleware to pass data to loaders.
|
|
252
|
+
*/
|
|
253
|
+
getBag<T>(key: string): T | undefined {
|
|
254
|
+
const ctx = storage.getStore();
|
|
255
|
+
return ctx?.bag.get(key) as T | undefined;
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
setBag(key: string, value: unknown): void {
|
|
259
|
+
const ctx = storage.getStore();
|
|
260
|
+
ctx?.bag.set(key, value);
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Get an app's state from the request bag.
|
|
265
|
+
* Apps register their state via `setupApps()` which injects it
|
|
266
|
+
* into the bag as `app:{name}:state` before each request.
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* ```ts
|
|
270
|
+
* import { RequestContext } from "@decocms/start/sdk/requestContext";
|
|
271
|
+
* import type { VtexState } from "@decocms/apps/vtex/mod";
|
|
272
|
+
*
|
|
273
|
+
* const vtex = RequestContext.getAppState<VtexState>("vtex");
|
|
274
|
+
* if (vtex) console.log(vtex.config.account);
|
|
275
|
+
* ```
|
|
276
|
+
*/
|
|
277
|
+
getAppState<T>(appName: string): T | undefined {
|
|
278
|
+
const ctx = storage.getStore();
|
|
279
|
+
return ctx?.bag.get(`app:${appName}:state`) as T | undefined;
|
|
280
|
+
},
|
|
281
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
// Import the browser stub explicitly by path. Vitest (no "browser" export
|
|
3
|
+
// condition) would otherwise resolve `@decocms/blocks/sdk/requestContextStorage`
|
|
4
|
+
// to the real `node:async_hooks`-backed module; here we assert the client-safe
|
|
5
|
+
// stand-in that Next.js's client webpack compiler selects via the "browser"
|
|
6
|
+
// export condition behaves exactly like the real storage does outside a request
|
|
7
|
+
// scope.
|
|
8
|
+
import { storage } from "./requestContextStorage.browser";
|
|
9
|
+
|
|
10
|
+
describe("requestContextStorage.browser (client-safe stub)", () => {
|
|
11
|
+
it("getStore() returns undefined — no per-request async context on the client", () => {
|
|
12
|
+
expect(storage.getStore()).toBeUndefined();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("run() invokes the callback inline and returns its value", () => {
|
|
16
|
+
let ran = false;
|
|
17
|
+
const result = storage.run({} as never, () => {
|
|
18
|
+
ran = true;
|
|
19
|
+
return 42;
|
|
20
|
+
});
|
|
21
|
+
expect(ran).toBe(true);
|
|
22
|
+
expect(result).toBe(42);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("run() does not establish a store — getStore() is still undefined inside the callback", () => {
|
|
26
|
+
const inside = storage.run({} as never, () => storage.getStore());
|
|
27
|
+
expect(inside).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-bundle stand-in for the `node:async_hooks`-backed request storage in
|
|
3
|
+
* `./requestContextStorage`. Selected automatically by bundlers that set the
|
|
4
|
+
* `"browser"` export condition (Next.js / webpack client compiler, Vite client
|
|
5
|
+
* build) via the conditional `exports` entry in `packages/blocks/package.json`.
|
|
6
|
+
*
|
|
7
|
+
* There is no per-request async context on the client, so this backend simply
|
|
8
|
+
* reports "no active store". Every `RequestContext` accessor built on top of it
|
|
9
|
+
* therefore behaves exactly as the real implementation does when called outside
|
|
10
|
+
* a request scope:
|
|
11
|
+
* - `RequestContext.current` / `requestId` → `null`
|
|
12
|
+
* - `RequestContext.request` / `signal` / `responseHeaders` → throw
|
|
13
|
+
* - `RequestContext.device` → `"desktop"`, `isBot` → `false`, `elapsed` → `0`
|
|
14
|
+
* - `RequestContext.fetch` → falls back to `globalThis.fetch`
|
|
15
|
+
*
|
|
16
|
+
* That is exactly the fall-through the sole client-reachable consumer
|
|
17
|
+
* (`resolveDeviceFromRuntime` in `./useDevice`) relies on: `current === null`
|
|
18
|
+
* → use the `navigator.userAgent` client path. Nothing on the client ever
|
|
19
|
+
* establishes a request scope, so `run()` never needs real async propagation —
|
|
20
|
+
* it just invokes the callback inline.
|
|
21
|
+
*
|
|
22
|
+
* `RequestContextStorageBackend` is imported `type`-only from the real
|
|
23
|
+
* `./requestContextStorage` module — this is erased entirely at compile time
|
|
24
|
+
* (by tsc and by the SWC pass Next.js runs before webpack ever sees the
|
|
25
|
+
* module graph), so it does NOT drag `node:async_hooks` back into this file's
|
|
26
|
+
* runtime output. Annotating `storage` with it means this stub and the real
|
|
27
|
+
* `AsyncLocalStorage`-backed implementation are compiler-checked against the
|
|
28
|
+
* exact same narrow surface — if one drifts (e.g. `requestContext.ts` starts
|
|
29
|
+
* calling a method neither backend exposes), `tsc` fails here, not silently
|
|
30
|
+
* at runtime on the client.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { RequestContextData } from "./requestContext";
|
|
34
|
+
import type { RequestContextStorageBackend } from "./requestContextStorage";
|
|
35
|
+
|
|
36
|
+
export const storage: RequestContextStorageBackend = {
|
|
37
|
+
getStore(): RequestContextData | undefined {
|
|
38
|
+
return undefined;
|
|
39
|
+
},
|
|
40
|
+
run<R>(_store: RequestContextData, fn: () => R): R {
|
|
41
|
+
return fn();
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node/Workers implementation of the per-request storage backend for
|
|
3
|
+
* `RequestContext` (see `./requestContext`).
|
|
4
|
+
*
|
|
5
|
+
* This is the ONE module in the `requestContext` graph that touches
|
|
6
|
+
* `node:async_hooks`. It is split out from `requestContext.ts` so that
|
|
7
|
+
* bundlers building for a browser target can swap it for the no-op stub in
|
|
8
|
+
* `./requestContextStorage.browser` via the `"browser"` export condition in
|
|
9
|
+
* `packages/blocks/package.json` — without that condition, Next.js's client
|
|
10
|
+
* webpack compiler tries to resolve the `node:async_hooks` specifier for a
|
|
11
|
+
* non-Node target and fails the build with `UnhandledSchemeError`, even though
|
|
12
|
+
* `AsyncLocalStorage` is only ever meaningfully used server-side.
|
|
13
|
+
*
|
|
14
|
+
* Requires `nodejs_compat` in wrangler.jsonc (already enabled) on Workers.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
18
|
+
import type { RequestContextData } from "./requestContext";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The narrow surface `requestContext.ts` actually calls (`storage.getStore()`
|
|
22
|
+
* / `storage.run(ctx, fn)`). Exporting `storage` typed as this interface —
|
|
23
|
+
* rather than letting it infer the full `AsyncLocalStorage<RequestContextData>`
|
|
24
|
+
* type — means `requestContext.ts` only ever sees these two members,
|
|
25
|
+
* regardless of which backend (this file or `./requestContextStorage.browser`)
|
|
26
|
+
* a given bundler's export condition resolves to. If `requestContext.ts` is
|
|
27
|
+
* ever edited to call another `AsyncLocalStorage` method (e.g. `enterWith`,
|
|
28
|
+
* `exit`, `disable`), that becomes an immediate compile error at the call
|
|
29
|
+
* site — forcing whoever adds it to widen this type here AND add the method
|
|
30
|
+
* to the browser stub, instead of silently working against the real backend
|
|
31
|
+
* while quietly breaking on the client.
|
|
32
|
+
*/
|
|
33
|
+
export type RequestContextStorageBackend = Pick<AsyncLocalStorage<RequestContextData>, "run" | "getStore">;
|
|
34
|
+
|
|
35
|
+
export const storage: RequestContextStorageBackend = new AsyncLocalStorage<RequestContextData>();
|
package/src/sdk/retry.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const CONNECTION_CLOSED_MESSAGE = "connection closed before message completed";
|
|
2
|
+
|
|
3
|
+
function sleep(ms: number): Promise<void> {
|
|
4
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Simple retry utility — replaces cockatiel to avoid module-level AbortController
|
|
9
|
+
* (cockatiel's abort.js creates new AbortController() at module scope, which is
|
|
10
|
+
* forbidden in Cloudflare Workers global scope).
|
|
11
|
+
*
|
|
12
|
+
* Retries up to maxAttempts when the error matches the predicate.
|
|
13
|
+
* Uses exponential backoff: delay = min(initialDelay * exponent^attempt, maxDelay).
|
|
14
|
+
*/
|
|
15
|
+
export function retryExceptionOr500() {
|
|
16
|
+
return {
|
|
17
|
+
execute: async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
18
|
+
const maxAttempts = 3;
|
|
19
|
+
const initialDelay = 100;
|
|
20
|
+
const maxDelay = 5000;
|
|
21
|
+
const exponent = 2;
|
|
22
|
+
|
|
23
|
+
let lastErr: unknown;
|
|
24
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
25
|
+
try {
|
|
26
|
+
return await fn();
|
|
27
|
+
} catch (err) {
|
|
28
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
29
|
+
if (!message.includes(CONNECTION_CLOSED_MESSAGE)) {
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
lastErr = err;
|
|
33
|
+
try {
|
|
34
|
+
console.error("retrying...", err);
|
|
35
|
+
} catch (_) {}
|
|
36
|
+
if (attempt < maxAttempts - 1) {
|
|
37
|
+
const delay = Math.min(initialDelay * Math.pow(exponent, attempt), maxDelay);
|
|
38
|
+
await sleep(delay);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
throw lastErr;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|