@decocms/tanstack 7.6.0 → 7.8.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 +6 -5
- package/src/cms/kvBlockSource.test.ts +24 -9
- package/src/cms/kvBlockSource.ts +24 -10
- package/src/routes/cmsRoute.ts +0 -29
- package/src/routes/index.ts +1 -1
- package/src/sdk/deferredSectionLoader.test.ts +94 -0
- package/src/sdk/deferredSectionLoader.ts +46 -0
- package/src/sdk/kvHydration.test.ts +55 -11
- package/src/sdk/kvHydration.ts +23 -6
- package/src/sdk/workerEntry.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/tanstack",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco framework binding for TanStack Start + Cloudflare Workers",
|
|
6
6
|
"repository": {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"./vite": "./src/vite/plugin.js",
|
|
15
15
|
"./daemon": "./src/daemon/index.ts",
|
|
16
16
|
"./sdk/createInvoke": "./src/sdk/createInvoke.ts",
|
|
17
|
-
"./sdk/cookiePassthrough": "./src/sdk/cookiePassthrough.ts"
|
|
17
|
+
"./sdk/cookiePassthrough": "./src/sdk/cookiePassthrough.ts",
|
|
18
|
+
"./sdk/deferredSectionLoader": "./src/sdk/deferredSectionLoader.ts"
|
|
18
19
|
},
|
|
19
20
|
"scripts": {
|
|
20
21
|
"build": "tsc",
|
|
@@ -23,9 +24,9 @@
|
|
|
23
24
|
"lint:unused": "knip"
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|
|
26
|
-
"@decocms/blocks": "7.
|
|
27
|
-
"@decocms/blocks-admin": "7.
|
|
28
|
-
"@decocms/blocks-cli": "7.
|
|
27
|
+
"@decocms/blocks": "7.8.0",
|
|
28
|
+
"@decocms/blocks-admin": "7.8.0",
|
|
29
|
+
"@decocms/blocks-cli": "7.8.0",
|
|
29
30
|
"@deco-cx/warp-node": "^0.3.16",
|
|
30
31
|
"fast-json-patch": "^3.1.0",
|
|
31
32
|
"ws": "^8.18.0"
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { computeRevision,
|
|
2
|
+
import { computeRevision, type KVNamespace, revisionKey, snapshotKey } from "@decocms/blocks/cms";
|
|
3
3
|
import { KVBlockSource } from "./kvBlockSource";
|
|
4
4
|
|
|
5
|
+
const ID = "sha-abc123";
|
|
6
|
+
const SNAP = snapshotKey(ID);
|
|
7
|
+
const REV = revisionKey(ID);
|
|
8
|
+
|
|
5
9
|
function makeKV(initial: Record<string, string> = {}): KVNamespace {
|
|
6
10
|
const store = new Map<string, string>(Object.entries(initial));
|
|
7
11
|
return {
|
|
@@ -19,7 +23,17 @@ function makeKV(initial: Record<string, string> = {}): KVNamespace {
|
|
|
19
23
|
|
|
20
24
|
describe("KVBlockSource.loadSnapshot", () => {
|
|
21
25
|
it("returns null when the snapshot key is missing", async () => {
|
|
22
|
-
const src = new KVBlockSource(makeKV());
|
|
26
|
+
const src = new KVBlockSource(makeKV(), ID);
|
|
27
|
+
await expect(src.loadSnapshot()).resolves.toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("reads the key for its own deployment id, not another", async () => {
|
|
31
|
+
const blocks = { Site: { name: "x" } };
|
|
32
|
+
// Store under a DIFFERENT deployment id — this source must not see it.
|
|
33
|
+
const src = new KVBlockSource(
|
|
34
|
+
makeKV({ [snapshotKey("other-sha")]: JSON.stringify(blocks) }),
|
|
35
|
+
ID,
|
|
36
|
+
);
|
|
23
37
|
await expect(src.loadSnapshot()).resolves.toBeNull();
|
|
24
38
|
});
|
|
25
39
|
|
|
@@ -27,16 +41,17 @@ describe("KVBlockSource.loadSnapshot", () => {
|
|
|
27
41
|
const blocks = { Site: { name: "x" } };
|
|
28
42
|
const src = new KVBlockSource(
|
|
29
43
|
makeKV({
|
|
30
|
-
[
|
|
31
|
-
[
|
|
44
|
+
[SNAP]: JSON.stringify(blocks),
|
|
45
|
+
[REV]: "stored-rev",
|
|
32
46
|
}),
|
|
47
|
+
ID,
|
|
33
48
|
);
|
|
34
49
|
await expect(src.loadSnapshot()).resolves.toEqual({ blocks, revision: "stored-rev" });
|
|
35
50
|
});
|
|
36
51
|
|
|
37
52
|
it("recomputes the revision when only the snapshot is stored", async () => {
|
|
38
53
|
const blocks = { Site: { name: "x" } };
|
|
39
|
-
const src = new KVBlockSource(makeKV({ [
|
|
54
|
+
const src = new KVBlockSource(makeKV({ [SNAP]: JSON.stringify(blocks) }), ID);
|
|
40
55
|
await expect(src.loadSnapshot()).resolves.toEqual({
|
|
41
56
|
blocks,
|
|
42
57
|
revision: computeRevision(blocks),
|
|
@@ -44,24 +59,24 @@ describe("KVBlockSource.loadSnapshot", () => {
|
|
|
44
59
|
});
|
|
45
60
|
|
|
46
61
|
it("throws on a non-object snapshot (treated as KV-unavailable by callers)", async () => {
|
|
47
|
-
const src = new KVBlockSource(makeKV({ [
|
|
62
|
+
const src = new KVBlockSource(makeKV({ [SNAP]: "[1,2,3]" }), ID);
|
|
48
63
|
await expect(src.loadSnapshot()).rejects.toThrow();
|
|
49
64
|
});
|
|
50
65
|
|
|
51
66
|
it("throws on malformed JSON", async () => {
|
|
52
|
-
const src = new KVBlockSource(makeKV({ [
|
|
67
|
+
const src = new KVBlockSource(makeKV({ [SNAP]: "{not json" }), ID);
|
|
53
68
|
await expect(src.loadSnapshot()).rejects.toThrow();
|
|
54
69
|
});
|
|
55
70
|
});
|
|
56
71
|
|
|
57
72
|
describe("KVBlockSource.getRevision", () => {
|
|
58
73
|
it("returns the stored revision", async () => {
|
|
59
|
-
const src = new KVBlockSource(makeKV({ [
|
|
74
|
+
const src = new KVBlockSource(makeKV({ [REV]: "r1" }), ID);
|
|
60
75
|
await expect(src.getRevision()).resolves.toBe("r1");
|
|
61
76
|
});
|
|
62
77
|
|
|
63
78
|
it("returns null when absent", async () => {
|
|
64
|
-
const src = new KVBlockSource(makeKV());
|
|
79
|
+
const src = new KVBlockSource(makeKV(), ID);
|
|
65
80
|
await expect(src.getRevision()).resolves.toBeNull();
|
|
66
81
|
});
|
|
67
82
|
});
|
package/src/cms/kvBlockSource.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* KVBlockSource — a `BlockSource` backed by a Cloudflare KV namespace
|
|
2
|
+
* KVBlockSource — a `BlockSource` backed by a Cloudflare KV namespace, scoped
|
|
3
|
+
* to a single **deployment id**.
|
|
3
4
|
*
|
|
4
|
-
* Reads the whole decofile snapshot (`decofile
|
|
5
|
-
* (`index:revision
|
|
5
|
+
* Reads the whole decofile snapshot (`decofile:<id>`) and its revision
|
|
6
|
+
* (`index:revision:<id>`) from KV. Used by the runtime hydration path
|
|
6
7
|
* (`src/sdk/kvHydration.ts`) on cold start and during revision polling, and by
|
|
7
8
|
* the write-through path (`src/admin/decofile.ts`) which writes the same keys.
|
|
9
|
+
* Keying by deployment id means each running version only ever sees its own
|
|
10
|
+
* content — a rolling deploy can't feed new content to still-live old code.
|
|
8
11
|
*
|
|
9
12
|
* This class is intentionally thin — error handling (KV outages, JSON parse
|
|
10
13
|
* failures) is the caller's responsibility so the framework can fall back to
|
|
@@ -15,15 +18,26 @@ import {
|
|
|
15
18
|
type BlockSnapshot,
|
|
16
19
|
type BlockSource,
|
|
17
20
|
computeRevision,
|
|
18
|
-
KV_KEYS,
|
|
19
21
|
type KVNamespace,
|
|
22
|
+
revisionKey,
|
|
23
|
+
snapshotKey,
|
|
20
24
|
} from "@decocms/blocks/cms";
|
|
21
25
|
|
|
22
26
|
export class KVBlockSource implements BlockSource {
|
|
23
|
-
|
|
27
|
+
private readonly snapshotKey: string;
|
|
28
|
+
private readonly revisionKey: string;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly kv: KVNamespace,
|
|
32
|
+
/** Deployment id (git commit sha) this source is scoped to. */
|
|
33
|
+
deploymentId: string,
|
|
34
|
+
) {
|
|
35
|
+
this.snapshotKey = snapshotKey(deploymentId);
|
|
36
|
+
this.revisionKey = revisionKey(deploymentId);
|
|
37
|
+
}
|
|
24
38
|
|
|
25
39
|
/**
|
|
26
|
-
* Read and parse
|
|
40
|
+
* Read and parse this deployment's full decofile snapshot from KV.
|
|
27
41
|
*
|
|
28
42
|
* Returns `null` when no snapshot is present (key missing) so the caller
|
|
29
43
|
* keeps whatever blocks are already in memory (the bundled fallback).
|
|
@@ -34,21 +48,21 @@ export class KVBlockSource implements BlockSource {
|
|
|
34
48
|
* unavailable" and falls back.
|
|
35
49
|
*/
|
|
36
50
|
async loadSnapshot(): Promise<BlockSnapshot | null> {
|
|
37
|
-
const raw = await this.kv.get(
|
|
51
|
+
const raw = await this.kv.get(this.snapshotKey);
|
|
38
52
|
if (raw === null) return null;
|
|
39
53
|
|
|
40
54
|
const parsed = JSON.parse(raw) as unknown;
|
|
41
55
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
42
|
-
throw new Error(`[CMS/KV] ${
|
|
56
|
+
throw new Error(`[CMS/KV] ${this.snapshotKey} is not a JSON object`);
|
|
43
57
|
}
|
|
44
58
|
const blocks = parsed as Record<string, unknown>;
|
|
45
59
|
|
|
46
|
-
const storedRevision = await this.kv.get(
|
|
60
|
+
const storedRevision = await this.kv.get(this.revisionKey);
|
|
47
61
|
return { blocks, revision: storedRevision ?? computeRevision(blocks) };
|
|
48
62
|
}
|
|
49
63
|
|
|
50
64
|
/** Cheap revision probe for change detection (no full snapshot transfer). */
|
|
51
65
|
getRevision(): Promise<string | null> {
|
|
52
|
-
return this.kv.get(
|
|
66
|
+
return this.kv.get(this.revisionKey);
|
|
53
67
|
}
|
|
54
68
|
}
|
package/src/routes/cmsRoute.ts
CHANGED
|
@@ -340,35 +340,6 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
|
|
|
340
340
|
return normalizeUrlsInObject(enriched);
|
|
341
341
|
});
|
|
342
342
|
|
|
343
|
-
// ---------------------------------------------------------------------------
|
|
344
|
-
// Pre-wrapped deferred section loader for IntersectionObserver-based rendering
|
|
345
|
-
// ---------------------------------------------------------------------------
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Convenience wrapper around `loadDeferredSection` that matches the
|
|
349
|
-
* `loadDeferredSectionFn` prop signature of `DecoPageRenderer`.
|
|
350
|
-
*
|
|
351
|
-
* Pass this directly to `<DecoPageRenderer loadDeferredSectionFn={deferredSectionLoader} />`
|
|
352
|
-
* to enable IntersectionObserver-based lazy loading of deferred sections.
|
|
353
|
-
*/
|
|
354
|
-
export const deferredSectionLoader = async ({
|
|
355
|
-
component,
|
|
356
|
-
rawProps,
|
|
357
|
-
pagePath,
|
|
358
|
-
pageUrl,
|
|
359
|
-
index,
|
|
360
|
-
}: {
|
|
361
|
-
component: string;
|
|
362
|
-
rawProps?: Record<string, unknown>;
|
|
363
|
-
pagePath: string;
|
|
364
|
-
pageUrl?: string;
|
|
365
|
-
index?: number;
|
|
366
|
-
}): Promise<ResolvedSection | null> => {
|
|
367
|
-
return loadDeferredSection({
|
|
368
|
-
data: { component, rawProps, pagePath, pageUrl, index },
|
|
369
|
-
});
|
|
370
|
-
};
|
|
371
|
-
|
|
372
343
|
// ---------------------------------------------------------------------------
|
|
373
344
|
// Default pending component — shown during SPA navigation while loader runs
|
|
374
345
|
// ---------------------------------------------------------------------------
|
package/src/routes/index.ts
CHANGED
|
@@ -10,12 +10,12 @@ export {
|
|
|
10
10
|
type CmsRouteOptions,
|
|
11
11
|
cmsHomeRouteConfig,
|
|
12
12
|
cmsRouteConfig,
|
|
13
|
-
deferredSectionLoader,
|
|
14
13
|
loadCmsHomePage,
|
|
15
14
|
loadCmsPage,
|
|
16
15
|
loadDeferredSection,
|
|
17
16
|
setSectionChunkMap,
|
|
18
17
|
} from "./cmsRoute";
|
|
18
|
+
export { deferredSectionLoader } from "../sdk/deferredSectionLoader";
|
|
19
19
|
export { CmsPage, NotFoundPage } from "./components";
|
|
20
20
|
export {
|
|
21
21
|
resolveSiteGlobals,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract test for the public `deferredSectionLoader` wrapper
|
|
3
|
+
* (`@decocms/tanstack/sdk/deferredSectionLoader`).
|
|
4
|
+
*
|
|
5
|
+
* Before 7.7 this wrapper was unreachable from any public subpath, so
|
|
6
|
+
* migrated sites (lebiscuit, miess, granadobr, casaevideo) each carried a
|
|
7
|
+
* byte-identical local shim wrapping the public `loadDeferredSection`
|
|
8
|
+
* export. The cases below are derived from how those sites call it: it is
|
|
9
|
+
* passed verbatim as `<DecoPageRenderer loadDeferredSectionFn={...} />`,
|
|
10
|
+
* so it must (a) accept the renderer's flat argument object, (b) wrap it
|
|
11
|
+
* into the server function's `{ data }` envelope, and (c) pass the result
|
|
12
|
+
* (section or null) straight through.
|
|
13
|
+
*/
|
|
14
|
+
import type { ComponentProps } from "react";
|
|
15
|
+
import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
|
|
16
|
+
|
|
17
|
+
vi.mock("../routes/cmsRoute", () => ({
|
|
18
|
+
loadDeferredSection: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import type { DecoPageRenderer } from "../hooks/DecoPageRenderer";
|
|
22
|
+
import { loadDeferredSection } from "../routes/cmsRoute";
|
|
23
|
+
import { deferredSectionLoader } from "./deferredSectionLoader";
|
|
24
|
+
|
|
25
|
+
const mockedLoad = loadDeferredSection as unknown as ReturnType<typeof vi.fn>;
|
|
26
|
+
|
|
27
|
+
describe("deferredSectionLoader", () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
mockedLoad.mockReset();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("wraps the flat argument object into the server function's { data } envelope", async () => {
|
|
33
|
+
const section = { component: "site/sections/Newsletter.tsx", props: {} };
|
|
34
|
+
mockedLoad.mockResolvedValue(section);
|
|
35
|
+
|
|
36
|
+
const result = await deferredSectionLoader({
|
|
37
|
+
component: "site/sections/Newsletter.tsx",
|
|
38
|
+
rawProps: { title: "Hi" },
|
|
39
|
+
pagePath: "/",
|
|
40
|
+
pageUrl: "https://example.com/?utm=x",
|
|
41
|
+
index: 4,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(mockedLoad).toHaveBeenCalledTimes(1);
|
|
45
|
+
expect(mockedLoad).toHaveBeenCalledWith({
|
|
46
|
+
data: {
|
|
47
|
+
component: "site/sections/Newsletter.tsx",
|
|
48
|
+
rawProps: { title: "Hi" },
|
|
49
|
+
pagePath: "/",
|
|
50
|
+
pageUrl: "https://example.com/?utm=x",
|
|
51
|
+
index: 4,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
expect(result).toBe(section);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("accepts the minimal call shape (component + pagePath only)", async () => {
|
|
58
|
+
mockedLoad.mockResolvedValue(null);
|
|
59
|
+
|
|
60
|
+
await deferredSectionLoader({
|
|
61
|
+
component: "site/sections/Footer.tsx",
|
|
62
|
+
pagePath: "/collections/sale",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(mockedLoad).toHaveBeenCalledWith({
|
|
66
|
+
data: {
|
|
67
|
+
component: "site/sections/Footer.tsx",
|
|
68
|
+
rawProps: undefined,
|
|
69
|
+
pagePath: "/collections/sale",
|
|
70
|
+
pageUrl: undefined,
|
|
71
|
+
index: undefined,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("passes a null resolution (cache miss / unresolvable section) straight through", async () => {
|
|
77
|
+
mockedLoad.mockResolvedValue(null);
|
|
78
|
+
|
|
79
|
+
const result = await deferredSectionLoader({
|
|
80
|
+
component: "site/sections/Missing.tsx",
|
|
81
|
+
pagePath: "/",
|
|
82
|
+
index: 0,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(result).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("matches DecoPageRenderer's loadDeferredSectionFn prop signature", () => {
|
|
89
|
+
// The whole point of the export: sites pass it verbatim as
|
|
90
|
+
// `<DecoPageRenderer loadDeferredSectionFn={deferredSectionLoader} />`.
|
|
91
|
+
type LoadFn = NonNullable<ComponentProps<typeof DecoPageRenderer>["loadDeferredSectionFn"]>;
|
|
92
|
+
expectTypeOf(deferredSectionLoader).toExtend<LoadFn>();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-wrapped deferred section loader for IntersectionObserver-based
|
|
3
|
+
* rendering.
|
|
4
|
+
*
|
|
5
|
+
* Convenience wrapper around `loadDeferredSection` (the POST server
|
|
6
|
+
* function defined in `../routes/cmsRoute.ts`) that matches the
|
|
7
|
+
* `loadDeferredSectionFn` prop signature of `DecoPageRenderer`.
|
|
8
|
+
*
|
|
9
|
+
* Pass this directly to
|
|
10
|
+
* `<DecoPageRenderer loadDeferredSectionFn={deferredSectionLoader} />`
|
|
11
|
+
* to enable IntersectionObserver-based lazy loading of deferred sections.
|
|
12
|
+
*
|
|
13
|
+
* Publicly exported at `@decocms/tanstack/sdk/deferredSectionLoader`.
|
|
14
|
+
* Before 7.7 this wrapper existed only inside the package (exported from
|
|
15
|
+
* the internal `./routes` barrel but reachable from no public subpath), so
|
|
16
|
+
* migrated sites each carried a byte-identical local shim re-implementing
|
|
17
|
+
* it against the public `loadDeferredSection` export. Those shims can be
|
|
18
|
+
* deleted in favor of this subpath.
|
|
19
|
+
*
|
|
20
|
+
* @example Site's `src/routes/$.tsx`:
|
|
21
|
+
* ```tsx
|
|
22
|
+
* import { deferredSectionLoader } from "@decocms/tanstack/sdk/deferredSectionLoader";
|
|
23
|
+
*
|
|
24
|
+
* <DecoPageRenderer loadDeferredSectionFn={deferredSectionLoader} />
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
import type { ResolvedSection } from "@decocms/blocks/cms";
|
|
28
|
+
import { loadDeferredSection } from "../routes/cmsRoute";
|
|
29
|
+
|
|
30
|
+
export const deferredSectionLoader = async ({
|
|
31
|
+
component,
|
|
32
|
+
rawProps,
|
|
33
|
+
pagePath,
|
|
34
|
+
pageUrl,
|
|
35
|
+
index,
|
|
36
|
+
}: {
|
|
37
|
+
component: string;
|
|
38
|
+
rawProps?: Record<string, unknown>;
|
|
39
|
+
pagePath: string;
|
|
40
|
+
pageUrl?: string;
|
|
41
|
+
index?: number;
|
|
42
|
+
}): Promise<ResolvedSection | null> => {
|
|
43
|
+
return loadDeferredSection({
|
|
44
|
+
data: { component, rawProps, pagePath, pageUrl, index },
|
|
45
|
+
});
|
|
46
|
+
};
|
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
computeRevision,
|
|
4
|
+
getRevision,
|
|
5
|
+
type KVNamespace,
|
|
6
|
+
loadBlocks,
|
|
7
|
+
revisionKey,
|
|
8
|
+
setBlocks,
|
|
9
|
+
snapshotKey,
|
|
10
|
+
} from "@decocms/blocks/cms";
|
|
3
11
|
import {
|
|
4
12
|
__resetKvHydrationStateForTests,
|
|
5
13
|
ensureBlocksHydrated,
|
|
14
|
+
getDeploymentId,
|
|
6
15
|
isFastDeployEnabled,
|
|
7
16
|
maybePollRevision,
|
|
8
17
|
} from "./kvHydration";
|
|
9
18
|
|
|
10
19
|
const BUNDLED = { Site: { name: "bundled" } };
|
|
11
20
|
|
|
21
|
+
/** Deployment id used across the hydration tests. */
|
|
22
|
+
const ID = "sha-deadbeef";
|
|
23
|
+
const SNAP = snapshotKey(ID);
|
|
24
|
+
const REV = revisionKey(ID);
|
|
25
|
+
|
|
12
26
|
/** KV stub that counts get() calls so we can assert throttling / single-load. */
|
|
13
27
|
function makeKV(initial: Record<string, string> = {}) {
|
|
14
28
|
const store = new Map<string, string>(Object.entries(initial));
|
|
@@ -32,11 +46,16 @@ function makeKV(initial: Record<string, string> = {}) {
|
|
|
32
46
|
|
|
33
47
|
function snapshotEnv(blocks: Record<string, unknown>, extra: Record<string, unknown> = {}) {
|
|
34
48
|
const { kv, ...rest } = makeKV({
|
|
35
|
-
[
|
|
36
|
-
[
|
|
49
|
+
[SNAP]: JSON.stringify(blocks),
|
|
50
|
+
[REV]: computeRevision(blocks),
|
|
37
51
|
});
|
|
38
|
-
// DECO_FAST_DEPLOY="1" is the explicit opt-in required alongside the binding
|
|
39
|
-
|
|
52
|
+
// DECO_FAST_DEPLOY="1" is the explicit opt-in required alongside the binding;
|
|
53
|
+
// DECO_DEPLOYMENT_ID scopes the read to this deployment's keyed snapshot.
|
|
54
|
+
return {
|
|
55
|
+
env: { DECO_KV: kv, DECO_FAST_DEPLOY: "1", DECO_DEPLOYMENT_ID: ID, ...extra },
|
|
56
|
+
kv,
|
|
57
|
+
...rest,
|
|
58
|
+
};
|
|
40
59
|
}
|
|
41
60
|
|
|
42
61
|
/** Collects ctx.waitUntil promises so tests can await background polls. */
|
|
@@ -110,10 +129,19 @@ describe("ensureBlocksHydrated", () => {
|
|
|
110
129
|
expect(getCalls()).toBe(2);
|
|
111
130
|
});
|
|
112
131
|
|
|
113
|
-
it("keeps the bundled snapshot when
|
|
114
|
-
const { kv } = makeKV({ [
|
|
115
|
-
await ensureBlocksHydrated({ DECO_KV: kv });
|
|
132
|
+
it("keeps the bundled snapshot when this deployment's snapshot key is absent", async () => {
|
|
133
|
+
const { kv } = makeKV({ [REV]: "r" }); // revision present, but no SNAPSHOT
|
|
134
|
+
await ensureBlocksHydrated({ DECO_KV: kv, DECO_FAST_DEPLOY: "1", DECO_DEPLOYMENT_ID: ID });
|
|
135
|
+
expect(loadBlocks()).toEqual(BUNDLED);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("keeps bundled (never touches KV) when no deployment id resolves", async () => {
|
|
139
|
+
const kvBlocks = { Site: { name: "from-kv" } };
|
|
140
|
+
const { kv, getCalls } = makeKV({ [SNAP]: JSON.stringify(kvBlocks) });
|
|
141
|
+
// Fast-deploy enabled + bound, but no DECO_DEPLOYMENT_ID / BUILD_HASH.
|
|
142
|
+
await ensureBlocksHydrated({ DECO_KV: kv, DECO_FAST_DEPLOY: "1" });
|
|
116
143
|
expect(loadBlocks()).toEqual(BUNDLED);
|
|
144
|
+
expect(getCalls()).toBe(0);
|
|
117
145
|
});
|
|
118
146
|
|
|
119
147
|
it("falls back to bundled (and does not throw) when KV errors", async () => {
|
|
@@ -123,11 +151,27 @@ describe("ensureBlocksHydrated", () => {
|
|
|
123
151
|
delete: () => Promise.resolve(),
|
|
124
152
|
};
|
|
125
153
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
126
|
-
await expect(
|
|
154
|
+
await expect(
|
|
155
|
+
ensureBlocksHydrated({ DECO_KV: kv, DECO_FAST_DEPLOY: "1", DECO_DEPLOYMENT_ID: ID }),
|
|
156
|
+
).resolves.toBeUndefined();
|
|
127
157
|
expect(loadBlocks()).toEqual(BUNDLED);
|
|
128
158
|
});
|
|
129
159
|
});
|
|
130
160
|
|
|
161
|
+
describe("getDeploymentId", () => {
|
|
162
|
+
it("prefers DECO_DEPLOYMENT_ID", () => {
|
|
163
|
+
expect(getDeploymentId({ DECO_DEPLOYMENT_ID: "a", BUILD_HASH: "b" })).toBe("a");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("falls back to BUILD_HASH", () => {
|
|
167
|
+
expect(getDeploymentId({ BUILD_HASH: "b" })).toBe("b");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("is null when nothing resolves", () => {
|
|
171
|
+
expect(getDeploymentId({})).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
131
175
|
describe("maybePollRevision", () => {
|
|
132
176
|
it("is a no-op before cold-start hydration has run", async () => {
|
|
133
177
|
const { env, getCalls } = snapshotEnv({ Site: { name: "x" } });
|
|
@@ -145,8 +189,8 @@ describe("maybePollRevision", () => {
|
|
|
145
189
|
|
|
146
190
|
// Simulate a publish from another isolate: KV now holds v2.
|
|
147
191
|
const updated = { Site: { name: "v2" }, "pages-x": { path: "/x" } };
|
|
148
|
-
store.set(
|
|
149
|
-
store.set(
|
|
192
|
+
store.set(SNAP, JSON.stringify(updated));
|
|
193
|
+
store.set(REV, computeRevision(updated));
|
|
150
194
|
|
|
151
195
|
const { ctx, settle } = makeCtx();
|
|
152
196
|
maybePollRevision(env, ctx);
|
package/src/sdk/kvHydration.ts
CHANGED
|
@@ -24,12 +24,12 @@
|
|
|
24
24
|
* `DecofileProvider` pattern from the deco-cx/deco Fresh runtime.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { getRevision, setBlocks } from "@decocms/blocks/cms";
|
|
27
|
+
import { getDeploymentId, getRevision, setBlocks } from "@decocms/blocks/cms";
|
|
28
28
|
import { KVBlockSource } from "../cms/kvBlockSource";
|
|
29
29
|
import type { KVNamespace } from "@decocms/blocks/cms";
|
|
30
30
|
import { setSpanAttribute } from "@decocms/blocks/sdk/observability";
|
|
31
31
|
|
|
32
|
-
/** How often (ms) an isolate re-probes `index:revision
|
|
32
|
+
/** How often (ms) an isolate re-probes its `index:revision:<id>`. */
|
|
33
33
|
export const POLL_INTERVAL_MS = 10_000;
|
|
34
34
|
|
|
35
35
|
/** KV binding name expected on the Worker `env`. */
|
|
@@ -39,6 +39,11 @@ export const KV_BINDING = "DECO_KV";
|
|
|
39
39
|
* binding must also be present. */
|
|
40
40
|
export const FAST_DEPLOY_ENV = "DECO_FAST_DEPLOY";
|
|
41
41
|
|
|
42
|
+
// Deployment-id resolution lives in `@decocms/blocks/cms` (next to the key
|
|
43
|
+
// builders it feeds) so the runtime read path and the admin write-through
|
|
44
|
+
// resolve it identically. Re-exported for the existing call sites/tests.
|
|
45
|
+
export { getDeploymentId };
|
|
46
|
+
|
|
42
47
|
// globalThis-backed state so all Vite server-function split-module copies share
|
|
43
48
|
// the same hydration flags (same pattern as `loader.ts`).
|
|
44
49
|
const G = globalThis as unknown as {
|
|
@@ -105,9 +110,18 @@ export function ensureBlocksHydrated(env: Env, _ctx?: ExecutionContextLike): Pro
|
|
|
105
110
|
const kv = getKV(env);
|
|
106
111
|
if (!kv) return Promise.resolve();
|
|
107
112
|
|
|
113
|
+
// No resolvable deployment id ⇒ keep the bundled snapshot (this build's own
|
|
114
|
+
// content). Never read another deployment's key.
|
|
115
|
+
const deploymentId = getDeploymentId(env);
|
|
116
|
+
if (!deploymentId) {
|
|
117
|
+
setSpanAttribute("deco.block.source", "bundled");
|
|
118
|
+
G.__deco!.kvHydrated = true;
|
|
119
|
+
return Promise.resolve();
|
|
120
|
+
}
|
|
121
|
+
|
|
108
122
|
const load = (async () => {
|
|
109
123
|
try {
|
|
110
|
-
const snapshot = await new KVBlockSource(kv).loadSnapshot();
|
|
124
|
+
const snapshot = await new KVBlockSource(kv, deploymentId).loadSnapshot();
|
|
111
125
|
if (snapshot) {
|
|
112
126
|
setBlocks(snapshot.blocks);
|
|
113
127
|
setSpanAttribute("deco.block.source", "kv");
|
|
@@ -144,16 +158,19 @@ export function maybePollRevision(env: Env, ctx?: ExecutionContextLike): void {
|
|
|
144
158
|
const kv = getKV(env);
|
|
145
159
|
if (!kv) return;
|
|
146
160
|
|
|
147
|
-
const
|
|
161
|
+
const deploymentId = getDeploymentId(env);
|
|
162
|
+
if (!deploymentId) return; // bundled-only; nothing to poll
|
|
163
|
+
|
|
164
|
+
const poll = pollRevisionOnce(kv, deploymentId);
|
|
148
165
|
// Prefer waitUntil so the work outlives the response; fall back to a
|
|
149
166
|
// fire-and-forget promise (dev / tests) with its rejection swallowed.
|
|
150
167
|
if (ctx?.waitUntil) ctx.waitUntil(poll);
|
|
151
168
|
else void poll.catch(() => {});
|
|
152
169
|
}
|
|
153
170
|
|
|
154
|
-
async function pollRevisionOnce(kv: KVNamespace): Promise<void> {
|
|
171
|
+
async function pollRevisionOnce(kv: KVNamespace, deploymentId: string): Promise<void> {
|
|
155
172
|
try {
|
|
156
|
-
const source = new KVBlockSource(kv);
|
|
173
|
+
const source = new KVBlockSource(kv, deploymentId);
|
|
157
174
|
const remoteRevision = await source.getRevision();
|
|
158
175
|
if (!remoteRevision || remoteRevision === getRevision()) return;
|
|
159
176
|
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -122,7 +122,7 @@ function appendResourceHints(resp: Response): void {
|
|
|
122
122
|
|
|
123
123
|
/**
|
|
124
124
|
* Minimal ExecutionContext interface compatible with Cloudflare Workers.
|
|
125
|
-
* Defined here so
|
|
125
|
+
* Defined here so blocks doesn't need @cloudflare/workers-types.
|
|
126
126
|
*/
|
|
127
127
|
interface WorkerExecutionContext {
|
|
128
128
|
waitUntil(promise: Promise<unknown>): void;
|