@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
package/package.json
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@decocms/blocks",
|
|
3
|
+
"version": "7.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deco framework-agnostic core: CMS block resolution, section registry, matchers, portable SDK utilities",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./cms": "./src/cms/index.ts",
|
|
10
|
+
"./cms/loadDecofileDirectory": "./src/cms/loadDecofileDirectory.ts",
|
|
11
|
+
"./hooks": "./src/hooks/index.ts",
|
|
12
|
+
"./types": "./src/types/index.ts",
|
|
13
|
+
"./types/widgets": "./src/types/widgets.ts",
|
|
14
|
+
"./setup": "./src/setup.ts",
|
|
15
|
+
"./matchers/builtins": "./src/matchers/builtins.ts",
|
|
16
|
+
"./matchers/posthog": "./src/matchers/posthog.ts",
|
|
17
|
+
"./matchers/override": "./src/matchers/override.ts",
|
|
18
|
+
"./middleware": "./src/middleware/index.ts",
|
|
19
|
+
"./middleware/healthMetrics": "./src/middleware/healthMetrics.ts",
|
|
20
|
+
"./middleware/hydrationContext": "./src/middleware/hydrationContext.ts",
|
|
21
|
+
"./middleware/observability": "./src/middleware/observability.ts",
|
|
22
|
+
"./middleware/validateSection": "./src/middleware/validateSection.ts",
|
|
23
|
+
"./sdk": "./src/sdk/index.ts",
|
|
24
|
+
"./sdk/abTesting": "./src/sdk/abTesting.ts",
|
|
25
|
+
"./sdk/analytics": "./src/sdk/analytics.ts",
|
|
26
|
+
"./sdk/cachedLoader": "./src/sdk/cachedLoader.ts",
|
|
27
|
+
"./sdk/cacheHeaders": "./src/sdk/cacheHeaders.ts",
|
|
28
|
+
"./sdk/clx": "./src/sdk/clx.ts",
|
|
29
|
+
"./sdk/cn": "./src/sdk/cn.ts",
|
|
30
|
+
"./sdk/composite": "./src/sdk/composite.ts",
|
|
31
|
+
"./sdk/cookie": "./src/sdk/cookie.ts",
|
|
32
|
+
"./sdk/crypto": "./src/sdk/crypto.ts",
|
|
33
|
+
"./sdk/csp": "./src/sdk/csp.ts",
|
|
34
|
+
"./sdk/djb2": "./src/sdk/djb2.ts",
|
|
35
|
+
"./sdk/encoding": "./src/sdk/encoding.ts",
|
|
36
|
+
"./sdk/env": "./src/sdk/env.ts",
|
|
37
|
+
"./sdk/http": "./src/sdk/http.ts",
|
|
38
|
+
"./sdk/instrumentedFetch": "./src/sdk/instrumentedFetch.ts",
|
|
39
|
+
"./sdk/invoke": "./src/sdk/invoke.ts",
|
|
40
|
+
"./sdk/logger": "./src/sdk/logger.ts",
|
|
41
|
+
"./sdk/mergeCacheControl": "./src/sdk/mergeCacheControl.ts",
|
|
42
|
+
"./sdk/normalizeUrls": "./src/sdk/normalizeUrls.ts",
|
|
43
|
+
"./sdk/observability": "./src/sdk/observability.ts",
|
|
44
|
+
"./sdk/otel": "./src/sdk/otel.ts",
|
|
45
|
+
"./sdk/otelAdapters": "./src/sdk/otelAdapters.ts",
|
|
46
|
+
"./sdk/otelAdapters/clickhouseCollector": "./src/sdk/otelAdapters/clickhouseCollector.ts",
|
|
47
|
+
"./sdk/otelHttpLog": "./src/sdk/otelHttpLog.ts",
|
|
48
|
+
"./sdk/otelHttpMeter": "./src/sdk/otelHttpMeter.ts",
|
|
49
|
+
"./sdk/otelHttpTracer": "./src/sdk/otelHttpTracer.ts",
|
|
50
|
+
"./sdk/redirects": "./src/sdk/redirects.ts",
|
|
51
|
+
"./sdk/requestContext": "./src/sdk/requestContext.ts",
|
|
52
|
+
"./sdk/requestContextStorage": {
|
|
53
|
+
"workerd": "./src/sdk/requestContextStorage.ts",
|
|
54
|
+
"node": "./src/sdk/requestContextStorage.ts",
|
|
55
|
+
"browser": "./src/sdk/requestContextStorage.browser.ts",
|
|
56
|
+
"default": "./src/sdk/requestContextStorage.ts"
|
|
57
|
+
},
|
|
58
|
+
"./sdk/retry": "./src/sdk/retry.ts",
|
|
59
|
+
"./sdk/serverTimings": "./src/sdk/serverTimings.ts",
|
|
60
|
+
"./sdk/signal": "./src/sdk/signal.ts",
|
|
61
|
+
"./sdk/sitemap": "./src/sdk/sitemap.ts",
|
|
62
|
+
"./sdk/urlUtils": "./src/sdk/urlUtils.ts",
|
|
63
|
+
"./sdk/useDevice": "./src/sdk/useDevice.ts",
|
|
64
|
+
"./sdk/useId": "./src/sdk/useId.ts",
|
|
65
|
+
"./sdk/useScript": "./src/sdk/useScript.ts",
|
|
66
|
+
"./sdk/useSuggestions": "./src/sdk/useSuggestions.ts",
|
|
67
|
+
"./sdk/wrapCaughtErrors": "./src/sdk/wrapCaughtErrors.ts"
|
|
68
|
+
},
|
|
69
|
+
"scripts": {
|
|
70
|
+
"build": "tsc",
|
|
71
|
+
"test": "vitest run --root ../.. packages/blocks/",
|
|
72
|
+
"typecheck": "tsc --noEmit",
|
|
73
|
+
"lint:unused": "knip"
|
|
74
|
+
},
|
|
75
|
+
"dependencies": {
|
|
76
|
+
"@opentelemetry/api": "^1.9.1",
|
|
77
|
+
"@opentelemetry/semantic-conventions": "^1.28.0",
|
|
78
|
+
"@tanstack/store": "^0.9.1",
|
|
79
|
+
"clsx": "^2.1.1",
|
|
80
|
+
"fast-json-patch": "^3.1.0",
|
|
81
|
+
"tailwind-merge": "^3.3.1"
|
|
82
|
+
},
|
|
83
|
+
"peerDependencies": {
|
|
84
|
+
"react": "^19.0.0",
|
|
85
|
+
"react-dom": "^19.0.0"
|
|
86
|
+
},
|
|
87
|
+
"devDependencies": {
|
|
88
|
+
"@types/react": "^19.0.0",
|
|
89
|
+
"@types/react-dom": "^19.0.0",
|
|
90
|
+
"knip": "^5.86.0",
|
|
91
|
+
"typescript": "^5.9.0"
|
|
92
|
+
},
|
|
93
|
+
"publishConfig": {
|
|
94
|
+
"registry": "https://registry.npmjs.org",
|
|
95
|
+
"access": "public"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies convention-based section metadata generated by generate-sections.ts.
|
|
3
|
+
*
|
|
4
|
+
* Reads the generated sectionMeta/syncComponents/loadingFallbacks and calls
|
|
5
|
+
* the appropriate registration functions — replacing manual calls to
|
|
6
|
+
* registerSectionsSync, setAsyncRenderingConfig, registerCacheableSections,
|
|
7
|
+
* registerLayoutSections, registerSeoSections, and registerSection.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
registerSection,
|
|
11
|
+
registerSectionsSync,
|
|
12
|
+
} from "./registry";
|
|
13
|
+
import {
|
|
14
|
+
type CacheableSectionInput,
|
|
15
|
+
registerCacheableSections,
|
|
16
|
+
registerLayoutSections,
|
|
17
|
+
} from "./sectionLoaders";
|
|
18
|
+
import {
|
|
19
|
+
type AsyncRenderingConfig,
|
|
20
|
+
registerEagerSections,
|
|
21
|
+
registerNeverDeferSections,
|
|
22
|
+
registerSeoSections,
|
|
23
|
+
setAsyncRenderingConfig,
|
|
24
|
+
getAsyncRenderingConfig,
|
|
25
|
+
} from "./resolve";
|
|
26
|
+
|
|
27
|
+
export interface SectionMetaEntry {
|
|
28
|
+
eager?: boolean;
|
|
29
|
+
neverDefer?: boolean;
|
|
30
|
+
cache?: string;
|
|
31
|
+
layout?: boolean;
|
|
32
|
+
sync?: boolean;
|
|
33
|
+
clientOnly?: boolean;
|
|
34
|
+
seo?: boolean;
|
|
35
|
+
hasLoadingFallback?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ApplySectionConventionsInput {
|
|
39
|
+
/** Section metadata map from sections.gen.ts */
|
|
40
|
+
meta: Record<string, SectionMetaEntry>;
|
|
41
|
+
/** Sync-imported section modules from sections.gen.ts */
|
|
42
|
+
syncComponents?: Record<string, any>;
|
|
43
|
+
/** LoadingFallback components from sections.gen.ts */
|
|
44
|
+
loadingFallbacks?: Record<string, React.ComponentType<any>>;
|
|
45
|
+
/** Lazy section loaders from import.meta.glob (used for clientOnly/loadingFallback registration) */
|
|
46
|
+
sectionGlob?: Record<string, () => Promise<any>>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function applySectionConventions(input: ApplySectionConventionsInput): void {
|
|
50
|
+
const { meta, syncComponents, loadingFallbacks, sectionGlob } = input;
|
|
51
|
+
|
|
52
|
+
const eagerSections: string[] = [];
|
|
53
|
+
const neverDeferSections: string[] = [];
|
|
54
|
+
const layoutSections: string[] = [];
|
|
55
|
+
const seoSections: string[] = [];
|
|
56
|
+
const cacheableSections: Record<string, CacheableSectionInput> = {};
|
|
57
|
+
|
|
58
|
+
for (const [key, entry] of Object.entries(meta)) {
|
|
59
|
+
if (entry.eager) eagerSections.push(key);
|
|
60
|
+
if (entry.neverDefer) neverDeferSections.push(key);
|
|
61
|
+
if (entry.layout) layoutSections.push(key);
|
|
62
|
+
if (entry.seo) seoSections.push(key);
|
|
63
|
+
if (entry.cache) cacheableSections[key] = entry.cache as CacheableSectionInput;
|
|
64
|
+
|
|
65
|
+
if (entry.clientOnly && sectionGlob) {
|
|
66
|
+
const globKey = sectionGlobKey(key, sectionGlob);
|
|
67
|
+
if (globKey) {
|
|
68
|
+
registerSection(key, sectionGlob[globKey] as any, { clientOnly: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (entry.hasLoadingFallback && loadingFallbacks?.[key] && sectionGlob) {
|
|
73
|
+
const globKey = sectionGlobKey(key, sectionGlob);
|
|
74
|
+
if (globKey) {
|
|
75
|
+
registerSection(key, sectionGlob[globKey] as any, {
|
|
76
|
+
loadingFallback: loadingFallbacks[key],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (syncComponents && Object.keys(syncComponents).length > 0) {
|
|
83
|
+
registerSectionsSync(syncComponents);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (eagerSections.length > 0) {
|
|
87
|
+
registerEagerSections(eagerSections);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (neverDeferSections.length > 0) {
|
|
91
|
+
registerNeverDeferSections(neverDeferSections);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Always initialize asyncConfig so CMS Lazy deferral (`respectCmsLazy`) is
|
|
95
|
+
// active — this is what makes the admin ⚡ toggle the source of truth. Without
|
|
96
|
+
// it, asyncConfig stays null, `useAsync` is false in resolveDecoPage, and
|
|
97
|
+
// even editor-marked ⚡ sections render eagerly. (The default foldThreshold is
|
|
98
|
+
// Infinity, so position-based deferral stays off unless a site opts in.)
|
|
99
|
+
const existing: Partial<AsyncRenderingConfig> =
|
|
100
|
+
getAsyncRenderingConfig() ?? {};
|
|
101
|
+
setAsyncRenderingConfig({
|
|
102
|
+
...existing,
|
|
103
|
+
alwaysEager: [...(existing.alwaysEager ?? []), ...eagerSections],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (layoutSections.length > 0) {
|
|
107
|
+
registerLayoutSections(layoutSections);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (seoSections.length > 0) {
|
|
111
|
+
registerSeoSections(seoSections);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (Object.keys(cacheableSections).length > 0) {
|
|
115
|
+
registerCacheableSections(cacheableSections);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sectionGlobKey(
|
|
120
|
+
sectionKey: string,
|
|
121
|
+
glob: Record<string, () => Promise<any>>,
|
|
122
|
+
): string | null {
|
|
123
|
+
const relative = sectionKey.replace("site/sections/", "./sections/");
|
|
124
|
+
if (glob[relative]) return relative;
|
|
125
|
+
const withDot = sectionKey.replace("site/", "./");
|
|
126
|
+
if (glob[withDot]) return withDot;
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
BundledBlockSource,
|
|
4
|
+
computeRevision,
|
|
5
|
+
KV_KEYS,
|
|
6
|
+
type KVNamespace,
|
|
7
|
+
} from "./blockSource";
|
|
8
|
+
import { djb2Hex } from "../sdk/djb2";
|
|
9
|
+
|
|
10
|
+
describe("computeRevision", () => {
|
|
11
|
+
it("matches loader.ts computeRevision (djb2Hex of JSON.stringify)", () => {
|
|
12
|
+
const blocks = { Site: { name: "x" }, "pages-home": { path: "/" } };
|
|
13
|
+
expect(computeRevision(blocks)).toBe(djb2Hex(JSON.stringify(blocks)));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("is stable for the same input and differs on change", () => {
|
|
17
|
+
const a = { a: 1 };
|
|
18
|
+
const b = { a: 2 };
|
|
19
|
+
expect(computeRevision(a)).toBe(computeRevision(a));
|
|
20
|
+
expect(computeRevision(a)).not.toBe(computeRevision(b));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("hashes an empty decofile without throwing", () => {
|
|
24
|
+
expect(typeof computeRevision({})).toBe("string");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("BundledBlockSource", () => {
|
|
29
|
+
it("loadSnapshot resolves null (bundled is applied at startup, not here)", async () => {
|
|
30
|
+
const src = new BundledBlockSource();
|
|
31
|
+
await expect(src.loadSnapshot()).resolves.toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("getRevision resolves null", async () => {
|
|
35
|
+
const src = new BundledBlockSource();
|
|
36
|
+
await expect(src.getRevision()).resolves.toBeNull();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("KV_KEYS", () => {
|
|
41
|
+
it("exposes the snapshot and revision key contract", () => {
|
|
42
|
+
expect(KV_KEYS.SNAPSHOT).toBe("decofile:current");
|
|
43
|
+
expect(KV_KEYS.REVISION).toBe("index:revision");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("KVNamespace structural type", () => {
|
|
48
|
+
it("a plain Map-backed stub satisfies the interface", async () => {
|
|
49
|
+
const store = new Map<string, string>();
|
|
50
|
+
const kv: KVNamespace = {
|
|
51
|
+
get: (k) => Promise.resolve(store.get(k) ?? null),
|
|
52
|
+
put: (k, v) => {
|
|
53
|
+
store.set(k, v);
|
|
54
|
+
return Promise.resolve();
|
|
55
|
+
},
|
|
56
|
+
delete: (k) => {
|
|
57
|
+
store.delete(k);
|
|
58
|
+
return Promise.resolve();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
await kv.put(KV_KEYS.REVISION, "abc");
|
|
63
|
+
await expect(kv.get(KV_KEYS.REVISION)).resolves.toBe("abc");
|
|
64
|
+
await kv.delete(KV_KEYS.REVISION);
|
|
65
|
+
await expect(kv.get(KV_KEYS.REVISION)).resolves.toBeNull();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlockSource — the runtime source of truth for the CMS decofile.
|
|
3
|
+
*
|
|
4
|
+
* A `BlockSource` asynchronously yields the *whole* decofile snapshot (the
|
|
5
|
+
* `Record<string, unknown>` blocks map) plus its revision hash. This is the
|
|
6
|
+
* "fast deploy" seam: the framework can hydrate the in-memory block map from a
|
|
7
|
+
* remote source (Cloudflare KV) on cold start and swap it on revision change,
|
|
8
|
+
* WITHOUT making the synchronous resolution hot path (`loadBlocks()` in
|
|
9
|
+
* `loader.ts`) async.
|
|
10
|
+
*
|
|
11
|
+
* Design note (whole-snapshot vs per-block): the resolver calls `loadBlocks()`
|
|
12
|
+
* synchronously in dozens of places (matchers, deferral checks, page lookup,
|
|
13
|
+
* SEO). Rather than threading `async` through all of that, a `BlockSource`
|
|
14
|
+
* loads the entire decofile once and the framework calls `setBlocks()` to swap
|
|
15
|
+
* the in-memory map. Per-request reads stay in-memory (zero added latency); KV
|
|
16
|
+
* is touched only on cold start and during the opportunistic revision poll.
|
|
17
|
+
*
|
|
18
|
+
* Mirrors the `DecofileProvider` pattern from the original deco-cx/deco Fresh
|
|
19
|
+
* runtime (`engine/decofile/provider.ts`), trimmed to what the snapshot-swap
|
|
20
|
+
* model needs.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { djb2Hex } from "../sdk/djb2";
|
|
24
|
+
|
|
25
|
+
/** A fully-loaded decofile snapshot: the blocks map and its revision hash. */
|
|
26
|
+
export interface BlockSnapshot {
|
|
27
|
+
/** The decofile — a flat map of block name → block JSON. */
|
|
28
|
+
blocks: Record<string, unknown>;
|
|
29
|
+
/** DJB2 hex revision computed over the blocks (see `computeRevision`). */
|
|
30
|
+
revision: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Source of the runtime decofile snapshot.
|
|
35
|
+
*
|
|
36
|
+
* Implementations:
|
|
37
|
+
* - `BundledBlockSource` — the build-time `blocks.gen` snapshot (fallback /
|
|
38
|
+
* local dev). A no-op here because `setup.ts` already loads it via
|
|
39
|
+
* `setBlocks()` at module init.
|
|
40
|
+
* - `KVBlockSource` (PR2) — reads `decofile:current` + `index:revision` from a
|
|
41
|
+
* Cloudflare KV namespace.
|
|
42
|
+
*/
|
|
43
|
+
export interface BlockSource {
|
|
44
|
+
/**
|
|
45
|
+
* Load the full decofile snapshot. Returns `null` when this source has no
|
|
46
|
+
* snapshot to offer (e.g. KV key missing, or the bundled source which is
|
|
47
|
+
* already applied at startup) — the caller then keeps whatever blocks are
|
|
48
|
+
* currently in memory.
|
|
49
|
+
*/
|
|
50
|
+
loadSnapshot(): Promise<BlockSnapshot | null>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Cheap revision probe used for change detection during polling. Returns the
|
|
54
|
+
* current revision without transferring the full snapshot, or `null` when
|
|
55
|
+
* unavailable.
|
|
56
|
+
*/
|
|
57
|
+
getRevision(): Promise<string | null>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compute the revision hash for a blocks map.
|
|
62
|
+
*
|
|
63
|
+
* MUST match `computeRevision` in `loader.ts` (DJB2 over the JSON string) so
|
|
64
|
+
* that a snapshot written to KV and the revision stored alongside it agree
|
|
65
|
+
* with the revision an isolate computes after `setBlocks()` — otherwise the
|
|
66
|
+
* poller would see a permanent mismatch and reload on every tick.
|
|
67
|
+
*/
|
|
68
|
+
export function computeRevision(blocks: Record<string, unknown>): string {
|
|
69
|
+
return djb2Hex(JSON.stringify(blocks));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Bundled (build-time) snapshot source.
|
|
74
|
+
*
|
|
75
|
+
* Intentionally a no-op `loadSnapshot()`: the bundled `blocks.gen` is applied
|
|
76
|
+
* by `createSiteSetup()` → `setBlocks()` at module load, before any request.
|
|
77
|
+
* This exists so callers can treat "bundled" uniformly through the
|
|
78
|
+
* `BlockSource` interface and so the composition layer (KV primary + bundled
|
|
79
|
+
* fallback) has a concrete fallback object.
|
|
80
|
+
*/
|
|
81
|
+
export class BundledBlockSource implements BlockSource {
|
|
82
|
+
loadSnapshot(): Promise<BlockSnapshot | null> {
|
|
83
|
+
return Promise.resolve(null);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getRevision(): Promise<string | null> {
|
|
87
|
+
return Promise.resolve(null);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Minimal Cloudflare KV type
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Minimal structural type for a Cloudflare KV namespace binding.
|
|
97
|
+
*
|
|
98
|
+
* Declared locally (matching the pattern in `workerEntry.ts`, which defines
|
|
99
|
+
* its own `WorkerExecutionContext`) so `@decocms/start` does not depend on
|
|
100
|
+
* `@cloudflare/workers-types`. Only the methods the framework actually uses
|
|
101
|
+
* are modeled.
|
|
102
|
+
*/
|
|
103
|
+
export interface KVNamespace {
|
|
104
|
+
get(key: string, options?: { type?: "text" }): Promise<string | null>;
|
|
105
|
+
put(
|
|
106
|
+
key: string,
|
|
107
|
+
value: string,
|
|
108
|
+
options?: { expirationTtl?: number; metadata?: Record<string, unknown> },
|
|
109
|
+
): Promise<void>;
|
|
110
|
+
delete(key: string): Promise<void>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// KV key names — shared by the runtime reader (PR2), the write-through path
|
|
115
|
+
// (PR3), and the CI sync/migrate scripts (PR4). Single source of truth so the
|
|
116
|
+
// contract can't drift between read and write sides.
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
export const KV_KEYS = {
|
|
120
|
+
/** Full decofile JSON (the blocks map). */
|
|
121
|
+
SNAPSHOT: "decofile:current",
|
|
122
|
+
/** DJB2 hex revision of the snapshot — polled for change detection. */
|
|
123
|
+
REVISION: "index:revision",
|
|
124
|
+
} as const;
|
package/src/cms/index.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export type { BlockSnapshot, BlockSource, KVNamespace } from "./blockSource";
|
|
2
|
+
export { BundledBlockSource, computeRevision, KV_KEYS } from "./blockSource";
|
|
3
|
+
export type { DecoPage, Resolvable } from "./loader";
|
|
4
|
+
export {
|
|
5
|
+
findPageByPath,
|
|
6
|
+
getAllPages,
|
|
7
|
+
getRevision,
|
|
8
|
+
getSiteSeo,
|
|
9
|
+
loadBlocks,
|
|
10
|
+
onChange,
|
|
11
|
+
setBlocks,
|
|
12
|
+
withBlocksOverride,
|
|
13
|
+
} from "./loader";
|
|
14
|
+
export type { OnBeforeResolveProps, SectionModule, SectionOptions } from "./registry";
|
|
15
|
+
export {
|
|
16
|
+
getResolvedComponent,
|
|
17
|
+
getSection,
|
|
18
|
+
getSectionOptions,
|
|
19
|
+
getSectionRegistry,
|
|
20
|
+
getSyncComponent,
|
|
21
|
+
listRegisteredSections,
|
|
22
|
+
preloadSectionComponents,
|
|
23
|
+
preloadSectionModule,
|
|
24
|
+
registerOnBeforeResolveProps,
|
|
25
|
+
registerSection,
|
|
26
|
+
registerSections,
|
|
27
|
+
registerSectionsSync,
|
|
28
|
+
setResolvedComponent,
|
|
29
|
+
} from "./registry";
|
|
30
|
+
export type {
|
|
31
|
+
AsyncRenderingConfig,
|
|
32
|
+
CommerceLoader,
|
|
33
|
+
DanglingReferenceHandler,
|
|
34
|
+
DecoPageResult,
|
|
35
|
+
DeferredSection,
|
|
36
|
+
MatcherContext,
|
|
37
|
+
PageSeo,
|
|
38
|
+
ResolvedSection,
|
|
39
|
+
ResolveErrorHandler,
|
|
40
|
+
} from "./resolve";
|
|
41
|
+
export {
|
|
42
|
+
addSkipResolveType,
|
|
43
|
+
evaluateMatcher,
|
|
44
|
+
extractSeoFromProps,
|
|
45
|
+
extractSeoFromSections,
|
|
46
|
+
cacheDeferredRawProps,
|
|
47
|
+
getAsyncRenderingConfig,
|
|
48
|
+
getDeferredRawProps,
|
|
49
|
+
isBot,
|
|
50
|
+
isSeoSection,
|
|
51
|
+
onBeforeResolve,
|
|
52
|
+
reExtractRawProps,
|
|
53
|
+
registerBotPattern,
|
|
54
|
+
registerCommerceLoader,
|
|
55
|
+
registerCommerceLoaders,
|
|
56
|
+
unregisterCommerceLoader,
|
|
57
|
+
clearCommerceLoaders,
|
|
58
|
+
registerMatcher,
|
|
59
|
+
registerEagerSections,
|
|
60
|
+
registerNeverDeferSections,
|
|
61
|
+
registerSeoSections,
|
|
62
|
+
resolveDecoPage,
|
|
63
|
+
resolvePageSections,
|
|
64
|
+
resolvePageSeoBlock,
|
|
65
|
+
resolveDeferredSection,
|
|
66
|
+
resolveDeferredSectionFull,
|
|
67
|
+
resolveValue,
|
|
68
|
+
setAsyncRenderingConfig,
|
|
69
|
+
setDanglingReferenceHandler,
|
|
70
|
+
setResolveErrorHandler,
|
|
71
|
+
WELL_KNOWN_TYPES,
|
|
72
|
+
} from "./resolve";
|
|
73
|
+
export type { SectionLoaderFn } from "./sectionLoaders";
|
|
74
|
+
export {
|
|
75
|
+
isLayoutSection,
|
|
76
|
+
registerCacheableSections,
|
|
77
|
+
registerLayoutSections,
|
|
78
|
+
registerSectionLoader,
|
|
79
|
+
registerSectionLoaders,
|
|
80
|
+
runSectionLoaders,
|
|
81
|
+
runSingleSectionLoader,
|
|
82
|
+
unregisterLayoutSections,
|
|
83
|
+
} from "./sectionLoaders";
|
|
84
|
+
export {
|
|
85
|
+
compose,
|
|
86
|
+
withDevice,
|
|
87
|
+
withMobile,
|
|
88
|
+
withSearchParam,
|
|
89
|
+
withSectionLoader,
|
|
90
|
+
} from "./sectionMixins";
|
|
91
|
+
export type { ApplySectionConventionsInput, SectionMetaEntry } from "./applySectionConventions";
|
|
92
|
+
export { applySectionConventions } from "./applySectionConventions";
|
|
93
|
+
export type { ActionConfig, LoaderConfig, MatcherConfig, MetaResponse } from "./schema";
|
|
94
|
+
export {
|
|
95
|
+
composeMeta,
|
|
96
|
+
getRegisteredLoaders,
|
|
97
|
+
getRegisteredMatchers,
|
|
98
|
+
registerActionSchema,
|
|
99
|
+
registerActionSchemas,
|
|
100
|
+
registerLoaderSchema,
|
|
101
|
+
registerLoaderSchemas,
|
|
102
|
+
registerMatcherSchema,
|
|
103
|
+
registerMatcherSchemas,
|
|
104
|
+
} from "./schema";
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
registerLayoutSections,
|
|
4
|
+
registerSections,
|
|
5
|
+
resolveDecoPage,
|
|
6
|
+
setBlocks,
|
|
7
|
+
unregisterLayoutSections,
|
|
8
|
+
} from "./index";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Regression test for the layout-cache index-corruption bug that shipped in
|
|
12
|
+
* @decocms/start@6.12.1 and was fixed in 6.12.2 (deco-start commit 73d4f19,
|
|
13
|
+
* "fix(cms): don't mutate shared cached section objects when stamping
|
|
14
|
+
* index"). Two live production sites (casaevideo-tanstack, bagaggio-tanstack)
|
|
15
|
+
* hit this in the wild as an intermittent "footer renders above other
|
|
16
|
+
* sections" bug under concurrent traffic.
|
|
17
|
+
*
|
|
18
|
+
* Mechanism: resolveDecoPage caches a layout section's (Header/Footer)
|
|
19
|
+
* resolved output in a module-level Map, shared across every request that
|
|
20
|
+
* references the same layout block — and dedupes concurrent in-flight
|
|
21
|
+
* resolutions to the same shared Promise. Each request then stamps its own
|
|
22
|
+
* page's flat position onto `.index` so mergeSections can sort eager +
|
|
23
|
+
* deferred sections back into CMS order. If two concurrent requests need the
|
|
24
|
+
* SAME cached layout section at DIFFERENT flat positions (e.g. page A has
|
|
25
|
+
* Footer at index 0, page B has Footer at index 2), and stamping mutates the
|
|
26
|
+
* shared cached object in place, whichever request's stamp lands last wins —
|
|
27
|
+
* and both requests then see that same (possibly wrong-for-them) index.
|
|
28
|
+
*
|
|
29
|
+
* The fix clones the wrapper object before stamping, so each caller gets its
|
|
30
|
+
* own `{ ...section, index }` and the shared cache/in-flight objects are
|
|
31
|
+
* never touched.
|
|
32
|
+
*/
|
|
33
|
+
describe("resolveDecoPage — layout section cache does not leak `.index` across concurrent requests", () => {
|
|
34
|
+
it("gives each page its own correct Footer index when resolved concurrently", async () => {
|
|
35
|
+
const FOOTER_TYPE = "site/sections/RaceFooter.tsx";
|
|
36
|
+
const FILLER_TYPE = "site/sections/RaceFiller.tsx";
|
|
37
|
+
|
|
38
|
+
// A controllable delay on Footer's resolver forces both concurrent
|
|
39
|
+
// resolveDecoPage() calls to land on the SAME in-flight promise
|
|
40
|
+
// (resolvedLayoutInflight), reproducing the exact shared-object window
|
|
41
|
+
// the original bug required — without this, the two calls might resolve
|
|
42
|
+
// sequentially and never actually share state.
|
|
43
|
+
let releaseFooter: () => void = () => {};
|
|
44
|
+
const footerGate = new Promise<void>((resolve) => {
|
|
45
|
+
releaseFooter = resolve;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
registerSections({
|
|
49
|
+
[FOOTER_TYPE]: async () => {
|
|
50
|
+
await footerGate;
|
|
51
|
+
return { default: () => null };
|
|
52
|
+
},
|
|
53
|
+
[FILLER_TYPE]: async () => ({ default: () => null }),
|
|
54
|
+
});
|
|
55
|
+
registerLayoutSections([FOOTER_TYPE]);
|
|
56
|
+
|
|
57
|
+
setBlocks({
|
|
58
|
+
"pages-race-a": {
|
|
59
|
+
path: "/race-a",
|
|
60
|
+
// Footer at flat index 0 for this page.
|
|
61
|
+
sections: [{ __resolveType: FOOTER_TYPE }, { __resolveType: FILLER_TYPE }],
|
|
62
|
+
},
|
|
63
|
+
"pages-race-b": {
|
|
64
|
+
path: "/race-b",
|
|
65
|
+
// Same Footer, but at flat index 2 for this page.
|
|
66
|
+
sections: [
|
|
67
|
+
{ __resolveType: FILLER_TYPE },
|
|
68
|
+
{ __resolveType: FILLER_TYPE },
|
|
69
|
+
{ __resolveType: FOOTER_TYPE },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const pending = Promise.all([resolveDecoPage("/race-a"), resolveDecoPage("/race-b")]);
|
|
76
|
+
|
|
77
|
+
// Let both resolveDecoPage() calls start and reach the shared
|
|
78
|
+
// in-flight Footer promise before releasing it — this is what forces
|
|
79
|
+
// them onto the SAME cached/in-flight object rather than resolving
|
|
80
|
+
// one after the other.
|
|
81
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
82
|
+
releaseFooter();
|
|
83
|
+
|
|
84
|
+
const [pageA, pageB] = await pending;
|
|
85
|
+
|
|
86
|
+
const footerA = pageA?.resolvedSections.find((s) => s.component === FOOTER_TYPE);
|
|
87
|
+
const footerB = pageB?.resolvedSections.find((s) => s.component === FOOTER_TYPE);
|
|
88
|
+
|
|
89
|
+
expect(footerA).toBeDefined();
|
|
90
|
+
expect(footerB).toBeDefined();
|
|
91
|
+
|
|
92
|
+
// The bug: mutating a shared cached object in place means both pages
|
|
93
|
+
// would observe whichever index was stamped last (either both 0 or
|
|
94
|
+
// both 2), not their own correct position.
|
|
95
|
+
expect(footerA?.index).toBe(0);
|
|
96
|
+
expect(footerB?.index).toBe(2);
|
|
97
|
+
|
|
98
|
+
// The fix clones the wrapper before stamping — each page's Footer
|
|
99
|
+
// section must be its own object, not the same shared reference.
|
|
100
|
+
expect(footerA).not.toBe(footerB);
|
|
101
|
+
} finally {
|
|
102
|
+
unregisterLayoutSections([FOOTER_TYPE]);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("mechanism check: mutating a shared object in place under concurrent stamping loses one side's index (isolated, framework-independent)", async () => {
|
|
107
|
+
// This isolates the exact bug mechanism from the diff that shipped in
|
|
108
|
+
// 6.12.1 and was reverted after the rollback, independent of the CMS
|
|
109
|
+
// registry/cache machinery — proof that the failure mode is a plain JS
|
|
110
|
+
// shared-mutable-object race, not something specific to this framework's
|
|
111
|
+
// plumbing.
|
|
112
|
+
type Wrapper = { component: string; index?: number };
|
|
113
|
+
|
|
114
|
+
const sharedCached: Wrapper[] = [{ component: "Footer" }];
|
|
115
|
+
|
|
116
|
+
// Old (buggy) behavior: mutate the shared array's objects in place.
|
|
117
|
+
function stampBuggy(indexForThisCaller: number): Promise<Wrapper[]> {
|
|
118
|
+
return Promise.resolve(sharedCached).then((sections) => {
|
|
119
|
+
for (const s of sections) s.index = indexForThisCaller;
|
|
120
|
+
return sections;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// New (fixed) behavior: clone before stamping.
|
|
125
|
+
function stampFixed(indexForThisCaller: number): Promise<Wrapper[]> {
|
|
126
|
+
return Promise.resolve(sharedCached).then((sections) =>
|
|
127
|
+
sections.map((s) => ({ ...s, index: indexForThisCaller })),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Two "requests" concurrently want different indices for the same
|
|
132
|
+
// shared cached object. With the buggy stamper, whichever `.then()`
|
|
133
|
+
// callback runs last (caller B, since microtasks run in scheduling
|
|
134
|
+
// order) overwrites the object caller A already received a *reference*
|
|
135
|
+
// to — so caller A's array ends up reporting caller B's index too.
|
|
136
|
+
const [buggyA, buggyB] = await Promise.all([stampBuggy(0), stampBuggy(2)]);
|
|
137
|
+
expect(buggyA[0].index).toBe(2); // <- corrupted: A wanted 0, sees 2
|
|
138
|
+
expect(buggyB[0].index).toBe(2);
|
|
139
|
+
expect(buggyA[0]).toBe(buggyB[0]); // same object reference — the bug
|
|
140
|
+
|
|
141
|
+
const [fixedA, fixedB] = await Promise.all([stampFixed(0), stampFixed(2)]);
|
|
142
|
+
expect(fixedA[0].index).toBe(0); // <- correct
|
|
143
|
+
expect(fixedB[0].index).toBe(2); // <- correct
|
|
144
|
+
expect(fixedA[0]).not.toBe(fixedB[0]); // independent objects — the fix
|
|
145
|
+
});
|
|
146
|
+
});
|