@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.
Files changed (111) hide show
  1. package/package.json +97 -0
  2. package/src/cms/applySectionConventions.ts +128 -0
  3. package/src/cms/blockSource.test.ts +67 -0
  4. package/src/cms/blockSource.ts +124 -0
  5. package/src/cms/index.ts +104 -0
  6. package/src/cms/layoutCacheRace.test.ts +146 -0
  7. package/src/cms/loadDecofileDirectory.test.ts +52 -0
  8. package/src/cms/loadDecofileDirectory.ts +48 -0
  9. package/src/cms/loader.test.ts +184 -0
  10. package/src/cms/loader.ts +284 -0
  11. package/src/cms/registry.test.ts +118 -0
  12. package/src/cms/registry.ts +252 -0
  13. package/src/cms/resolve.test.ts +547 -0
  14. package/src/cms/resolve.ts +2003 -0
  15. package/src/cms/schema.ts +993 -0
  16. package/src/cms/sectionLoaders.test.ts +409 -0
  17. package/src/cms/sectionLoaders.ts +562 -0
  18. package/src/cms/sectionMixins.test.ts +163 -0
  19. package/src/cms/sectionMixins.ts +153 -0
  20. package/src/hooks/LazySection.tsx +121 -0
  21. package/src/hooks/LiveControls.tsx +122 -0
  22. package/src/hooks/RenderSection.test.tsx +81 -0
  23. package/src/hooks/RenderSection.tsx +91 -0
  24. package/src/hooks/SectionErrorFallback.tsx +97 -0
  25. package/src/hooks/index.ts +4 -0
  26. package/src/index.ts +8 -0
  27. package/src/matchers/builtins.test.ts +251 -0
  28. package/src/matchers/builtins.ts +437 -0
  29. package/src/matchers/countryNames.ts +104 -0
  30. package/src/matchers/override.test.ts +205 -0
  31. package/src/matchers/override.ts +136 -0
  32. package/src/matchers/posthog.ts +154 -0
  33. package/src/middleware/decoState.ts +55 -0
  34. package/src/middleware/healthMetrics.ts +133 -0
  35. package/src/middleware/hydrationContext.test.ts +61 -0
  36. package/src/middleware/hydrationContext.ts +79 -0
  37. package/src/middleware/index.ts +88 -0
  38. package/src/middleware/liveness.ts +21 -0
  39. package/src/middleware/observability.test.ts +238 -0
  40. package/src/middleware/observability.ts +620 -0
  41. package/src/middleware/validateSection.test.ts +147 -0
  42. package/src/middleware/validateSection.ts +100 -0
  43. package/src/sdk/abTesting.test.ts +326 -0
  44. package/src/sdk/abTesting.ts +499 -0
  45. package/src/sdk/analytics.ts +77 -0
  46. package/src/sdk/cacheHeaders.test.ts +115 -0
  47. package/src/sdk/cacheHeaders.ts +424 -0
  48. package/src/sdk/cachedLoader.ts +364 -0
  49. package/src/sdk/clx.ts +5 -0
  50. package/src/sdk/cn.test.ts +34 -0
  51. package/src/sdk/cn.ts +28 -0
  52. package/src/sdk/composite.test.ts +121 -0
  53. package/src/sdk/composite.ts +114 -0
  54. package/src/sdk/cookie.test.ts +108 -0
  55. package/src/sdk/cookie.ts +129 -0
  56. package/src/sdk/crypto.ts +185 -0
  57. package/src/sdk/csp.ts +59 -0
  58. package/src/sdk/djb2.ts +20 -0
  59. package/src/sdk/encoding.test.ts +71 -0
  60. package/src/sdk/encoding.ts +47 -0
  61. package/src/sdk/env.ts +31 -0
  62. package/src/sdk/http.test.ts +71 -0
  63. package/src/sdk/http.ts +124 -0
  64. package/src/sdk/index.ts +90 -0
  65. package/src/sdk/inflightTimeout.test.ts +35 -0
  66. package/src/sdk/inflightTimeout.ts +53 -0
  67. package/src/sdk/instrumentedFetch.test.ts +559 -0
  68. package/src/sdk/instrumentedFetch.ts +339 -0
  69. package/src/sdk/invoke.test.ts +115 -0
  70. package/src/sdk/invoke.ts +260 -0
  71. package/src/sdk/logger.test.ts +432 -0
  72. package/src/sdk/logger.ts +304 -0
  73. package/src/sdk/mergeCacheControl.ts +150 -0
  74. package/src/sdk/normalizeUrls.ts +91 -0
  75. package/src/sdk/observability.ts +109 -0
  76. package/src/sdk/otel.test.ts +526 -0
  77. package/src/sdk/otel.ts +981 -0
  78. package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
  79. package/src/sdk/otelAdapters.test.ts +89 -0
  80. package/src/sdk/otelAdapters.ts +144 -0
  81. package/src/sdk/otelHttpLog.test.ts +457 -0
  82. package/src/sdk/otelHttpLog.ts +419 -0
  83. package/src/sdk/otelHttpMeter.test.ts +292 -0
  84. package/src/sdk/otelHttpMeter.ts +506 -0
  85. package/src/sdk/otelHttpTracer.test.ts +474 -0
  86. package/src/sdk/otelHttpTracer.ts +543 -0
  87. package/src/sdk/redirects.ts +225 -0
  88. package/src/sdk/requestContext.ts +281 -0
  89. package/src/sdk/requestContextStorage.browser.test.ts +29 -0
  90. package/src/sdk/requestContextStorage.browser.ts +43 -0
  91. package/src/sdk/requestContextStorage.ts +35 -0
  92. package/src/sdk/retry.ts +45 -0
  93. package/src/sdk/serverTimings.ts +68 -0
  94. package/src/sdk/signal.ts +42 -0
  95. package/src/sdk/sitemap.ts +160 -0
  96. package/src/sdk/urlRedaction.test.ts +73 -0
  97. package/src/sdk/urlRedaction.ts +82 -0
  98. package/src/sdk/urlUtils.ts +134 -0
  99. package/src/sdk/useDevice.test.ts +130 -0
  100. package/src/sdk/useDevice.ts +109 -0
  101. package/src/sdk/useDeviceContext.tsx +108 -0
  102. package/src/sdk/useId.ts +7 -0
  103. package/src/sdk/useScript.test.ts +128 -0
  104. package/src/sdk/useScript.ts +210 -0
  105. package/src/sdk/useSuggestions.test.ts +230 -0
  106. package/src/sdk/useSuggestions.ts +188 -0
  107. package/src/sdk/wrapCaughtErrors.ts +107 -0
  108. package/src/setup.ts +119 -0
  109. package/src/types/index.ts +39 -0
  110. package/src/types/widgets.ts +14 -0
  111. 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>();
@@ -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
+ }