@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,230 @@
1
+ /**
2
+ * Tests for the `createUseSuggestions` factory.
3
+ *
4
+ * The hook itself depends on React (useCallback). This file exercises
5
+ * the parts of the factory that don't need a React renderer:
6
+ * - factory shape + isolation between calls
7
+ * - the non-React `_internal.setQuery` flow which carries every bit
8
+ * of behaviour the React hook delegates to (queue, cancel guard,
9
+ * loading-flag invariants, error path)
10
+ *
11
+ * Hook-level integration is exercised by the site-level smoke (the
12
+ * factory has shipped to two production sites with the same shape).
13
+ */
14
+
15
+ import { describe, expect, it, vi } from "vitest";
16
+ import { createUseSuggestions } from "./useSuggestions";
17
+
18
+ interface FakeSuggestion {
19
+ products: string[];
20
+ }
21
+
22
+ const FAKE_LOADER = {
23
+ __resolveType: "site/loaders/search/suggestions.ts",
24
+ limit: 5,
25
+ } as unknown as FakeSuggestion;
26
+
27
+ function makeOkFetch(payload: unknown, delayMs = 0): typeof fetch {
28
+ return ((_input: RequestInfo | URL, _init?: RequestInit) =>
29
+ new Promise((resolve) => {
30
+ setTimeout(
31
+ () =>
32
+ resolve(
33
+ new Response(JSON.stringify(payload), {
34
+ status: 200,
35
+ headers: { "Content-Type": "application/json" },
36
+ }),
37
+ ),
38
+ delayMs,
39
+ );
40
+ })) as typeof fetch;
41
+ }
42
+
43
+ describe("createUseSuggestions — factory shape", () => {
44
+ it("returns useSuggestions + _internal", () => {
45
+ const f = createUseSuggestions<FakeSuggestion>();
46
+ expect(typeof f.useSuggestions).toBe("function");
47
+ expect(typeof f._internal.setQuery).toBe("function");
48
+ expect(typeof f._internal.drain).toBe("function");
49
+ expect(f._internal.payload.value).toBeNull();
50
+ expect(f._internal.loading.value).toBe(false);
51
+ });
52
+
53
+ it("two factory calls produce independent state + functions", () => {
54
+ const a = createUseSuggestions<FakeSuggestion>();
55
+ const b = createUseSuggestions<FakeSuggestion>();
56
+ expect(a.useSuggestions).not.toBe(b.useSuggestions);
57
+ expect(a._internal.payload).not.toBe(b._internal.payload);
58
+ expect(a._internal.loading).not.toBe(b._internal.loading);
59
+ });
60
+ });
61
+
62
+ describe("createUseSuggestions — fetch happy path", () => {
63
+ it("posts to /deco/invoke/<__resolveType> with the query + extra props", async () => {
64
+ const spy = vi.fn(makeOkFetch({ products: ["a", "b"] }));
65
+ const f = createUseSuggestions<FakeSuggestion>({ fetchImpl: spy });
66
+ f._internal.setQuery("samsung", FAKE_LOADER);
67
+ await f._internal.drain();
68
+
69
+ expect(spy).toHaveBeenCalledTimes(1);
70
+ const [url, init] = spy.mock.calls[0];
71
+ expect(url).toBe("/deco/invoke/site/loaders/search/suggestions.ts");
72
+ expect(init?.method).toBe("POST");
73
+ expect(JSON.parse(init?.body as string)).toEqual({
74
+ query: "samsung",
75
+ limit: 5,
76
+ });
77
+ });
78
+
79
+ it("populates payload with the parsed response", async () => {
80
+ const f = createUseSuggestions<FakeSuggestion>({
81
+ fetchImpl: makeOkFetch({ products: ["a", "b"] }),
82
+ });
83
+ f._internal.setQuery("samsung", FAKE_LOADER);
84
+ await f._internal.drain();
85
+ expect(f._internal.payload.value).toEqual({ products: ["a", "b"] });
86
+ });
87
+
88
+ it("flips loading to true synchronously, back to false after fetch settles", async () => {
89
+ const f = createUseSuggestions<FakeSuggestion>({
90
+ fetchImpl: makeOkFetch({ products: [] }),
91
+ });
92
+ expect(f._internal.loading.value).toBe(false);
93
+ f._internal.setQuery("hi", FAKE_LOADER);
94
+ expect(f._internal.loading.value).toBe(true);
95
+ await f._internal.drain();
96
+ expect(f._internal.loading.value).toBe(false);
97
+ });
98
+ });
99
+
100
+ describe("createUseSuggestions — cancel + queue semantics", () => {
101
+ it("cancels older queries BEFORE they fetch — only the latest hits the network", async () => {
102
+ // Mock echoes the body's `query` field so we can tell which
103
+ // invocation actually reached the network.
104
+ const calls: string[] = [];
105
+ const fetchImpl: typeof fetch = ((_url, init) => {
106
+ const body = JSON.parse(init?.body as string) as { query: string };
107
+ calls.push(body.query);
108
+ return Promise.resolve(
109
+ new Response(JSON.stringify({ products: [body.query] }), {
110
+ status: 200,
111
+ }),
112
+ );
113
+ }) as typeof fetch;
114
+
115
+ const f = createUseSuggestions<FakeSuggestion>({ fetchImpl });
116
+ // Three queries kicked off back-to-back synchronously.
117
+ f._internal.setQuery("a", FAKE_LOADER);
118
+ f._internal.setQuery("b", FAKE_LOADER);
119
+ f._internal.setQuery("c", FAKE_LOADER);
120
+ await f._internal.drain();
121
+
122
+ // Only the latest query reaches the network — the cancel
123
+ // guard short-circuits the first two before they fetch.
124
+ expect(calls).toEqual(["c"]);
125
+ expect(f._internal.payload.value).toEqual({ products: ["c"] });
126
+ });
127
+
128
+ it("the latest-query guard prevents stale fetches from clearing loading prematurely", async () => {
129
+ // If the cancel guard ever regresses, this is the test that
130
+ // catches it: we kick off fetch #1, immediately call setQuery
131
+ // again, await drain, and expect the FINAL state to reflect
132
+ // the latest query — not an inconsistent "loading false but
133
+ // payload stale" mid-state.
134
+ const fetchImpl = makeOkFetch({ products: ["latest"] }, 5);
135
+ const f = createUseSuggestions<FakeSuggestion>({ fetchImpl });
136
+ f._internal.setQuery("a", FAKE_LOADER);
137
+ f._internal.setQuery("b", FAKE_LOADER);
138
+ f._internal.setQuery("c", FAKE_LOADER);
139
+ await f._internal.drain();
140
+ expect(f._internal.loading.value).toBe(false);
141
+ expect(f._internal.payload.value).toEqual({ products: ["latest"] });
142
+ });
143
+
144
+ it("queues serially — fetches don't run concurrently", async () => {
145
+ // Race detector: track whether the count of in-flight fetches
146
+ // ever exceeds 1.
147
+ let inflight = 0;
148
+ let maxInflight = 0;
149
+ const fetchImpl: typeof fetch = (() =>
150
+ new Promise<Response>((resolve) => {
151
+ inflight += 1;
152
+ maxInflight = Math.max(maxInflight, inflight);
153
+ setTimeout(() => {
154
+ inflight -= 1;
155
+ resolve(
156
+ new Response(JSON.stringify({ products: [] }), { status: 200 }),
157
+ );
158
+ }, 5);
159
+ })) as typeof fetch;
160
+
161
+ const f = createUseSuggestions<FakeSuggestion>({ fetchImpl });
162
+ f._internal.setQuery("a", FAKE_LOADER);
163
+ f._internal.setQuery("b", FAKE_LOADER);
164
+ f._internal.setQuery("c", FAKE_LOADER);
165
+ await f._internal.drain();
166
+ expect(maxInflight).toBe(1);
167
+ });
168
+ });
169
+
170
+ describe("createUseSuggestions — error path", () => {
171
+ it("forwards thrown errors to onError + console.error, does NOT update payload", async () => {
172
+ const onError = vi.fn();
173
+ const consoleError = vi
174
+ .spyOn(console, "error")
175
+ .mockImplementation(() => {});
176
+
177
+ const fetchImpl = (() =>
178
+ Promise.reject(new Error("network down"))) as typeof fetch;
179
+ const f = createUseSuggestions<FakeSuggestion>({ fetchImpl, onError });
180
+
181
+ f._internal.setQuery("samsung", FAKE_LOADER);
182
+ await f._internal.drain();
183
+
184
+ expect(onError).toHaveBeenCalledTimes(1);
185
+ const [err, query] = onError.mock.calls[0];
186
+ expect((err as Error).message).toBe("network down");
187
+ expect(query).toBe("samsung");
188
+ expect(consoleError).toHaveBeenCalled();
189
+ expect(f._internal.payload.value).toBeNull();
190
+ expect(f._internal.loading.value).toBe(false);
191
+
192
+ consoleError.mockRestore();
193
+ });
194
+
195
+ it("non-2xx responses surface as errors", async () => {
196
+ const onError = vi.fn();
197
+ const consoleError = vi
198
+ .spyOn(console, "error")
199
+ .mockImplementation(() => {});
200
+ const fetchImpl = (() =>
201
+ Promise.resolve(
202
+ new Response("internal error", { status: 500 }),
203
+ )) as typeof fetch;
204
+ const f = createUseSuggestions<FakeSuggestion>({ fetchImpl, onError });
205
+
206
+ f._internal.setQuery("x", FAKE_LOADER);
207
+ await f._internal.drain();
208
+
209
+ expect(onError).toHaveBeenCalledTimes(1);
210
+ expect((onError.mock.calls[0][0] as Error).message).toContain("500");
211
+ expect(f._internal.payload.value).toBeNull();
212
+ expect(f._internal.loading.value).toBe(false);
213
+
214
+ consoleError.mockRestore();
215
+ });
216
+
217
+ it("does NOT throw if onError is omitted (still console.errors)", async () => {
218
+ const consoleError = vi
219
+ .spyOn(console, "error")
220
+ .mockImplementation(() => {});
221
+ const fetchImpl = (() =>
222
+ Promise.reject(new Error("boom"))) as typeof fetch;
223
+ const f = createUseSuggestions<FakeSuggestion>({ fetchImpl });
224
+
225
+ f._internal.setQuery("x", FAKE_LOADER);
226
+ await expect(f._internal.drain()).resolves.toBeUndefined();
227
+ expect(consoleError).toHaveBeenCalled();
228
+ consoleError.mockRestore();
229
+ });
230
+ });
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Search-suggestions hook factory.
3
+ *
4
+ * Both casaevideo-storefront and baggagio-tanstack independently
5
+ * invented the same shape for autocomplete-style suggestions:
6
+ *
7
+ * - module-level signal for the current payload + loading flag
8
+ * - serial in-flight queue so older requests can't race past newer ones
9
+ * - "is this still the latest query?" cancel guard
10
+ * - posts to `/deco/invoke/<__resolveType>` with the loader's
11
+ * extra props + the live `query` string
12
+ *
13
+ * This factory is the canonical version. Sites instantiate it once
14
+ * at module load with their concrete suggestion type and (optionally)
15
+ * an `onError` hook for observability (Sentry / OpenTelemetry / etc.).
16
+ *
17
+ * Why a factory and not a plain hook:
18
+ * - Each call to `createUseSuggestions()` produces an isolated
19
+ * `payload` / `loading` / queue. Keeps the door open for sites
20
+ * with multiple independent suggestion streams (e.g. searchbar
21
+ * *and* a category-jump suggester) without globally-shared state.
22
+ * - Type narrowing happens at the factory boundary, not at hook
23
+ * usage — the returned `useSuggestions` is already specialised
24
+ * on `T` so callers don't need to re-pass generics.
25
+ * - Mirrors the existing `createUseCart` / `createUseUser` /
26
+ * `createUseWishlist` factory pattern from
27
+ * `@decocms/apps/vtex/hooks/*`. See
28
+ * `references/platform-hooks-factories.md`.
29
+ */
30
+
31
+ import { useCallback } from "react";
32
+ import type { Resolved } from "../types";
33
+ import { signal, type ReactiveSignal } from "./signal";
34
+
35
+ /**
36
+ * Optional dependencies the factory accepts at instantiation time.
37
+ *
38
+ * Most are pure observability hooks — the factory itself runs a
39
+ * `console.error()` after invoking `onError`, so callers don't have
40
+ * to remember to forward the error to the console themselves.
41
+ */
42
+ export interface UseSuggestionsOptions {
43
+ /**
44
+ * Called once per failed fetch with the original error and the
45
+ * query that triggered it. Use for Sentry / OTEL captures.
46
+ *
47
+ * The factory still calls `console.error` after this returns so
48
+ * sites that don't wire `onError` keep the same console output.
49
+ */
50
+ onError?: (error: unknown, query: string) => void;
51
+
52
+ /**
53
+ * Override the fetch implementation. Tests pass a stub here; the
54
+ * default is the global `fetch`. Production sites have no reason
55
+ * to set this.
56
+ */
57
+ fetchImpl?: typeof fetch;
58
+ }
59
+
60
+ /**
61
+ * Shape returned by the hook produced by `createUseSuggestions`.
62
+ *
63
+ * `loading` and `payload` are reactive signals — subscribe with
64
+ * `useStore()` from `@tanstack/react-store` (or read `.value`
65
+ * directly inside an `useEffect`).
66
+ */
67
+ export interface UseSuggestionsReturn<T> {
68
+ loading: ReactiveSignal<boolean>;
69
+ payload: ReactiveSignal<T | null>;
70
+ /**
71
+ * Trigger a suggestion fetch for `query`. Calls coalesce through
72
+ * a serial promise queue, and only the *latest* query's result
73
+ * is allowed to flip `loading` back to `false` — so rapid keystrokes
74
+ * don't leave the UI permanently in a stale loading state.
75
+ */
76
+ setQuery: (query: string) => void;
77
+ }
78
+
79
+ /**
80
+ * Returned by {@link createUseSuggestions}.
81
+ *
82
+ * `useSuggestions` is the React hook bound to this factory's state.
83
+ * The `_internal` field exposes the underlying signals and a non-
84
+ * React `setQuery` for advanced cases (SSR pre-population, unit
85
+ * tests, server-side warmup). Sites almost never need it.
86
+ */
87
+ export interface CreateUseSuggestionsReturn<T> {
88
+ useSuggestions: (loader: Resolved<T | null>) => UseSuggestionsReturn<T>;
89
+ _internal: {
90
+ readonly payload: ReactiveSignal<T | null>;
91
+ readonly loading: ReactiveSignal<boolean>;
92
+ /**
93
+ * Same semantics as `useSuggestions(...).setQuery`, but pure JS —
94
+ * no React hook context required. Useful in unit tests and for
95
+ * SSR pre-fetch helpers.
96
+ */
97
+ setQuery: (query: string, loader: Resolved<T | null>) => void;
98
+ /**
99
+ * Promise that resolves once every queued fetch has settled.
100
+ * Tests await this to assert post-cancellation state.
101
+ */
102
+ readonly drain: () => Promise<unknown>;
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Build a typed `useSuggestions` hook bound to a private
108
+ * `payload` / `loading` / queue tuple. Call once per stream at
109
+ * module load.
110
+ *
111
+ * @example
112
+ * // site/src/sdk/useSuggestions.ts
113
+ * import { createUseSuggestions } from "@decocms/start/sdk/useSuggestions";
114
+ * import * as Sentry from "@sentry/react";
115
+ * import type { Suggestion } from "@decocms/apps/commerce/types";
116
+ *
117
+ * export const { useSuggestions } = createUseSuggestions<Suggestion>({
118
+ * onError: (err) => Sentry.captureException(err),
119
+ * });
120
+ */
121
+ export function createUseSuggestions<T>(
122
+ options: UseSuggestionsOptions = {},
123
+ ): CreateUseSuggestionsReturn<T> {
124
+ const payload = signal<T | null>(null);
125
+ const loading = signal<boolean>(false);
126
+ let queue: Promise<unknown> = Promise.resolve();
127
+ let latestQuery = "";
128
+
129
+ const fetchImpl = options.fetchImpl ?? fetch;
130
+ const onError = options.onError;
131
+
132
+ async function doFetch(
133
+ query: string,
134
+ resolved: Resolved<T | null>,
135
+ ): Promise<void> {
136
+ if (latestQuery !== query) return;
137
+
138
+ const { __resolveType, ...extraProps } = resolved as {
139
+ __resolveType: string;
140
+ [k: string]: unknown;
141
+ };
142
+
143
+ try {
144
+ const response = await fetchImpl(`/deco/invoke/${__resolveType}`, {
145
+ method: "POST",
146
+ headers: { "Content-Type": "application/json" },
147
+ body: JSON.stringify({ query, ...extraProps }),
148
+ });
149
+ if (!response.ok) {
150
+ throw new Error(`Suggestions invoke failed: ${response.status}`);
151
+ }
152
+ payload.value = (await response.json()) as T | null;
153
+ } catch (error) {
154
+ onError?.(error, query);
155
+ console.error("[useSuggestions] fetch failed:", error);
156
+ } finally {
157
+ // Only the latest query is allowed to flip the loading flag —
158
+ // otherwise rapid keystrokes can leave the UI in a stale
159
+ // "still loading" state because an older fetch resolved last.
160
+ if (latestQuery === query) loading.value = false;
161
+ }
162
+ }
163
+
164
+ function setQueryImpl(query: string, loader: Resolved<T | null>): void {
165
+ loading.value = true;
166
+ latestQuery = query;
167
+ queue = queue.then(() => doFetch(query, loader));
168
+ }
169
+
170
+ function useSuggestions(loader: Resolved<T | null>): UseSuggestionsReturn<T> {
171
+ const setQuery = useCallback(
172
+ (query: string) => setQueryImpl(query, loader),
173
+ [loader],
174
+ );
175
+
176
+ return { loading, payload, setQuery };
177
+ }
178
+
179
+ return {
180
+ useSuggestions,
181
+ _internal: {
182
+ payload,
183
+ loading,
184
+ setQuery: setQueryImpl,
185
+ drain: () => queue,
186
+ },
187
+ };
188
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Error wrapping utility for loader/action results.
3
+ *
4
+ * Wraps a loader function so that thrown errors are caught and
5
+ * returned as a deferred error proxy object instead of crashing
6
+ * the entire page render.
7
+ *
8
+ * The proxy looks like the expected return type but throws the
9
+ * original error if any property is accessed -- this lets the
10
+ * section that uses the data decide whether to render a fallback
11
+ * or propagate the error to an ErrorBoundary.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * import { wrapCaughtErrors } from "@decocms/start/sdk/wrapCaughtErrors";
16
+ *
17
+ * const safeProductList = wrapCaughtErrors(vtexProductList);
18
+ * const products = await safeProductList({ query: "shoes" });
19
+ * // If vtexProductList threw, `products` is a proxy that throws on access
20
+ * ```
21
+ */
22
+
23
+ const ERROR_MARKER = Symbol("__decoWrappedError");
24
+
25
+ export interface WrappedError {
26
+ [ERROR_MARKER]: true;
27
+ error: unknown;
28
+ message: string;
29
+ }
30
+
31
+ /**
32
+ * Check if a value is a wrapped error proxy.
33
+ */
34
+ export function isWrappedError(value: unknown): value is WrappedError {
35
+ try {
36
+ return value != null && typeof value === "object" && (value as any)[ERROR_MARKER] === true;
37
+ } catch {
38
+ return true;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Extract the original error from a wrapped error.
44
+ */
45
+ export function unwrapError(value: unknown): unknown {
46
+ if (isWrappedError(value)) {
47
+ return value.error;
48
+ }
49
+ return value;
50
+ }
51
+
52
+ function createErrorProxy(error: unknown): any {
53
+ const message = error instanceof Error ? error.message : String(error);
54
+
55
+ const target = {
56
+ [ERROR_MARKER]: true,
57
+ error,
58
+ message,
59
+ };
60
+
61
+ return new Proxy(target, {
62
+ get(_target, prop) {
63
+ if (prop === ERROR_MARKER) return true;
64
+ if (prop === "error") return error;
65
+ if (prop === "message") return message;
66
+ if (prop === Symbol.toPrimitive) return () => `[WrappedError: ${message}]`;
67
+ if (prop === "toString") return () => `[WrappedError: ${message}]`;
68
+ if (prop === "toJSON") return () => ({ __error: true, message });
69
+
70
+ throw error;
71
+ },
72
+ has(_target, prop) {
73
+ return prop === ERROR_MARKER || prop === "error" || prop === "message";
74
+ },
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Wrap a loader/action function to catch errors and return a proxy.
80
+ *
81
+ * @param fn - The loader or action function to wrap
82
+ * @param onError - Optional error handler (for logging, metrics, etc.)
83
+ */
84
+ export function wrapCaughtErrors<TArgs extends unknown[], TReturn>(
85
+ fn: (...args: TArgs) => Promise<TReturn>,
86
+ onError?: (error: unknown, args: TArgs) => void,
87
+ ): (...args: TArgs) => Promise<TReturn> {
88
+ return async (...args: TArgs): Promise<TReturn> => {
89
+ try {
90
+ return await fn(...args);
91
+ } catch (error) {
92
+ onError?.(error, args);
93
+
94
+ const isDev =
95
+ typeof globalThis.process !== "undefined" &&
96
+ globalThis.process.env?.NODE_ENV === "development";
97
+ if (isDev) {
98
+ console.error(
99
+ `[wrapCaughtErrors] Loader failed:`,
100
+ error instanceof Error ? error.message : error,
101
+ );
102
+ }
103
+
104
+ return createErrorProxy(error) as TReturn;
105
+ }
106
+ };
107
+ }
package/src/setup.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * One-call site bootstrap for the framework-agnostic parts of a site:
3
+ * section registration, matchers, blocks, and CMS-resolution error
4
+ * handling. Sites that also need the admin protocol (meta schema, render
5
+ * shell, preview wrapper, commerce-loader-to-invoke wiring) call
6
+ * createAdminSetup() from @decocms/blocks-admin alongside this — the two
7
+ * were one function before the package split; they're split here because
8
+ * createAdminSetup's concerns require importing from admin, and
9
+ * runtime cannot depend on admin without creating a circular
10
+ * dependency (admin already depends on runtime).
11
+ *
12
+ * Everything site-specific (section loaders, cacheable sections, async
13
+ * rendering, layout sections, commerce loaders, sync sections) remains in
14
+ * the site's own setup file — createSiteSetup only handles the
15
+ * framework-generic wiring.
16
+ */
17
+
18
+ import {
19
+ loadBlocks,
20
+ onBeforeResolve,
21
+ registerSections,
22
+ setBlocks,
23
+ setDanglingReferenceHandler,
24
+ setResolveErrorHandler,
25
+ } from "./cms/index";
26
+ import { registerBuiltinMatchers } from "./matchers/builtins";
27
+ import { registerProductionOrigins } from "./sdk/normalizeUrls";
28
+
29
+ export interface SiteSetupOptions {
30
+ /**
31
+ * Section glob from Vite — pass `import.meta.glob("./sections/**\/*.tsx")`.
32
+ * Keys are transformed from `./sections/X.tsx` → `site/sections/X.tsx`.
33
+ */
34
+ sections: Record<string, () => Promise<any>>;
35
+
36
+ /**
37
+ * Generated blocks object — import and pass directly:
38
+ * `import { blocks } from "./server/cms/blocks.gen";`
39
+ */
40
+ blocks: Record<string, unknown>;
41
+
42
+ /** Production origins for URL normalization. */
43
+ productionOrigins?: string[];
44
+
45
+ /**
46
+ * Custom matcher registrations to run alongside builtins.
47
+ * Each function is called once during setup.
48
+ */
49
+ customMatchers?: Array<() => void>;
50
+
51
+ /** Error handler for CMS resolution errors. */
52
+ onResolveError?: (
53
+ error: unknown,
54
+ resolveType: string,
55
+ context: string,
56
+ ) => void;
57
+
58
+ /** Handler for dangling CMS references (missing __resolveType targets). */
59
+ onDanglingReference?: (resolveType: string) => any;
60
+
61
+ /**
62
+ * Called after blocks are loaded — use for platform initialization.
63
+ * Also called on every onBeforeResolve (decofile hot-reload).
64
+ */
65
+ initPlatform?: (blocks: any) => void;
66
+ }
67
+
68
+ /**
69
+ * Bootstrap a Deco site's framework-agnostic core — registers sections,
70
+ * matchers, blocks, and error handlers. Call once at the top of your
71
+ * setup.ts, before site-specific registrations, and alongside
72
+ * createAdminSetup() (@decocms/blocks-admin) if the site also needs the
73
+ * admin protocol.
74
+ */
75
+ export function createSiteSetup(options: SiteSetupOptions): void {
76
+ // 1. Error handlers (set first so they catch issues during registration)
77
+ if (options.onResolveError) {
78
+ setResolveErrorHandler(options.onResolveError);
79
+ }
80
+ if (options.onDanglingReference) {
81
+ setDanglingReferenceHandler(options.onDanglingReference);
82
+ }
83
+
84
+ // 2. Section glob registration — transform Vite paths to CMS keys
85
+ const sections: Record<string, () => Promise<any>> = {};
86
+ for (const [path, loader] of Object.entries(options.sections)) {
87
+ sections[`site/${path.slice(2)}`] = loader;
88
+ }
89
+ registerSections(sections);
90
+
91
+ // 3. Matchers
92
+ registerBuiltinMatchers();
93
+ if (options.customMatchers) {
94
+ for (const register of options.customMatchers) {
95
+ register();
96
+ }
97
+ }
98
+
99
+ // 4. Production origins
100
+ if (options.productionOrigins?.length) {
101
+ registerProductionOrigins(options.productionOrigins);
102
+ }
103
+
104
+ // 5. Blocks + platform init (server-only)
105
+ if (typeof document === "undefined") {
106
+ setBlocks(options.blocks);
107
+ if (options.initPlatform) {
108
+ options.initPlatform(loadBlocks());
109
+ }
110
+ }
111
+
112
+ // 6. onBeforeResolve — re-init platform on decofile hot-reload
113
+ if (options.initPlatform) {
114
+ const init = options.initPlatform;
115
+ onBeforeResolve(() => {
116
+ init(loadBlocks());
117
+ });
118
+ }
119
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Types that match @deco/deco's type exports.
3
+ * These allow storefront sites to use the same type interfaces
4
+ * without depending on the Deno-specific @deco/deco package.
5
+ */
6
+
7
+ export interface FnContext<TState = any> {
8
+ state: TState;
9
+ }
10
+
11
+ export type App<TManifest = any, TState = any, TDeps extends any[] = any[]> = {
12
+ state: TState;
13
+ manifest: TManifest;
14
+ dependencies?: TDeps;
15
+ };
16
+
17
+ export type AppContext<TApp extends App = App> = FnContext<TApp["state"]>;
18
+
19
+ export type Section<TProps = any> = {
20
+ Component: import("react").ComponentType<TProps>;
21
+ props: TProps;
22
+ };
23
+
24
+ export type SectionProps<TLoader = any, TAction = TLoader> = TLoader extends (
25
+ ...args: any[]
26
+ ) => Promise<infer R>
27
+ ? R
28
+ : TLoader;
29
+
30
+ export type Resolved<T> = T;
31
+
32
+ export interface LoadingFallbackProps<TProps = any> {
33
+ [key: string]: any;
34
+ }
35
+
36
+ export type Flag = {
37
+ name: string;
38
+ value: boolean;
39
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * CMS admin widget type aliases.
3
+ * These are string types that signal to the Deco CMS admin UI
4
+ * which editor widget to render for a given prop.
5
+ */
6
+ export type ImageWidget = string;
7
+ export type HTMLWidget = string;
8
+ export type VideoWidget = string;
9
+ export type TextWidget = string;
10
+ export type RichText = string;
11
+ export type Secret = string;
12
+ export type Color = string;
13
+ export type ButtonWidget = string;
14
+ export type TextArea = string;