@decocms/tanstack 7.33.1 → 7.34.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 +4 -4
- package/src/hooks/DecoRootLayout.tsx +2 -0
- package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
- package/src/hooks/DraftPreviewIndicator.tsx +62 -0
- package/src/hooks/index.ts +1 -0
- package/src/index.ts +1 -0
- package/src/sdk/draft.test.ts +231 -0
- package/src/sdk/draft.ts +254 -0
- package/src/sdk/draftShared.ts +16 -0
- package/src/sdk/workerEntry.test.ts +110 -0
- package/src/sdk/workerEntry.ts +62 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/tanstack",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.34.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco framework binding for TanStack Start + Cloudflare Workers",
|
|
6
6
|
"repository": {
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"lint:unused": "knip"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@decocms/blocks": "7.
|
|
28
|
-
"@decocms/blocks-admin": "7.
|
|
29
|
-
"@decocms/blocks-cli": "7.
|
|
27
|
+
"@decocms/blocks": "7.34.0",
|
|
28
|
+
"@decocms/blocks-admin": "7.34.0",
|
|
29
|
+
"@decocms/blocks-cli": "7.34.0",
|
|
30
30
|
"@deco-cx/warp-node": "^0.3.16",
|
|
31
31
|
"fast-json-patch": "^3.1.0",
|
|
32
32
|
"ws": "^8.18.0"
|
|
@@ -2,6 +2,7 @@ import { useEffect, type ReactNode } from "react";
|
|
|
2
2
|
import { HeadContent, Scripts, ScriptOnce } from "@tanstack/react-router";
|
|
3
3
|
import { LiveControls } from "@decocms/blocks/hooks";
|
|
4
4
|
import { ANALYTICS_SCRIPT } from "@decocms/blocks/sdk/analytics";
|
|
5
|
+
import { DraftPreviewIndicator } from "./DraftPreviewIndicator";
|
|
5
6
|
import { NavigationProgress } from "./NavigationProgress";
|
|
6
7
|
import { StableOutlet } from "./StableOutlet";
|
|
7
8
|
|
|
@@ -102,6 +103,7 @@ export function DecoRootLayout({
|
|
|
102
103
|
<StableOutlet />
|
|
103
104
|
</main>
|
|
104
105
|
{children}
|
|
106
|
+
<DraftPreviewIndicator />
|
|
105
107
|
<LiveControls site={siteName} />
|
|
106
108
|
<ScriptOnce children={ANALYTICS_SCRIPT} />
|
|
107
109
|
<Scripts />
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { DRAFT_POINTER_BAG_KEY, DRAFT_POINTER_GLOBAL } from "../sdk/draftShared";
|
|
8
|
+
import { DraftPreviewIndicator } from "./DraftPreviewIndicator";
|
|
9
|
+
|
|
10
|
+
const POINTER = "abc.preview-studio.decocms.com@v1";
|
|
11
|
+
|
|
12
|
+
function renderInRequest(pointer: string | null): string {
|
|
13
|
+
return RequestContext.run(new Request("https://example.com/"), () => {
|
|
14
|
+
if (pointer) RequestContext.setBag(DRAFT_POINTER_BAG_KEY, pointer);
|
|
15
|
+
return renderToString(<DraftPreviewIndicator />);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("DraftPreviewIndicator — SSR", () => {
|
|
20
|
+
it("publishes the pointer on a global so client hydration matches", () => {
|
|
21
|
+
const html = renderInRequest(POINTER);
|
|
22
|
+
expect(html).toContain(`window.${DRAFT_POINTER_GLOBAL}=`);
|
|
23
|
+
expect(html).toContain(JSON.stringify(POINTER));
|
|
24
|
+
// The chip itself fails closed — nothing visible until the client effect.
|
|
25
|
+
expect(html).not.toContain("Preview mode");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("renders nothing when the request is not drafting", () => {
|
|
29
|
+
expect(renderInRequest(null)).toBe("");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("DraftPreviewIndicator — client", () => {
|
|
34
|
+
let container: HTMLDivElement;
|
|
35
|
+
let root: Root;
|
|
36
|
+
const win = window as unknown as Record<string, string | undefined>;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
container = document.createElement("div");
|
|
40
|
+
document.body.appendChild(container);
|
|
41
|
+
root = createRoot(container);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
act(() => root.unmount());
|
|
46
|
+
container.remove();
|
|
47
|
+
win[DRAFT_POINTER_GLOBAL] = undefined;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("mounts the badge from the published global (cookie-nav, no bag on the client)", () => {
|
|
51
|
+
win[DRAFT_POINTER_GLOBAL] = POINTER;
|
|
52
|
+
act(() => root.render(<DraftPreviewIndicator />));
|
|
53
|
+
expect(container.textContent).toContain("Preview mode");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("renders nothing when no pointer is published", () => {
|
|
57
|
+
act(() => root.render(<DraftPreviewIndicator />));
|
|
58
|
+
expect(container.textContent).toBe("");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview-mode badge gate — the TanStack binding.
|
|
3
|
+
*
|
|
4
|
+
* Mounts the shared `DraftPreviewBadge` (`@decocms/blocks/preview`) only when
|
|
5
|
+
* the request is rendering a draft. `DecoRootLayout` renders it automatically,
|
|
6
|
+
* so sites get the badge on a package bump with no code change; it renders
|
|
7
|
+
* nothing at all on ordinary (published) traffic.
|
|
8
|
+
*
|
|
9
|
+
* ## Isomorphic pointer, hydration-safe
|
|
10
|
+
*
|
|
11
|
+
* TanStack Start renders this component on BOTH server and client (no RSC
|
|
12
|
+
* boundary). The pointer only exists server-side (the request bag), so:
|
|
13
|
+
*
|
|
14
|
+
* - **Server:** read it from `RequestContext` and emit a tiny inline `<script>`
|
|
15
|
+
* that publishes it on `window.<GLOBAL>`. Inline scripts execute during HTML
|
|
16
|
+
* parse, before hydration.
|
|
17
|
+
* - **Client:** `RequestContext` resolves to a no-op stub in the browser
|
|
18
|
+
* bundle (the `browser` export condition), so the bag read returns
|
|
19
|
+
* undefined and we fall back to `window.<GLOBAL>` — which the just-executed
|
|
20
|
+
* inline script set to the server's value, so the hydration render computes
|
|
21
|
+
* the SAME pointer and the `<script>` node reconciles cleanly.
|
|
22
|
+
*
|
|
23
|
+
* Reading the bag first (then the global) — rather than branching on
|
|
24
|
+
* `typeof window` — is also what makes this testable under jsdom, where
|
|
25
|
+
* `window` always exists.
|
|
26
|
+
*
|
|
27
|
+
* The badge itself starts hidden and reveals via a client effect (fails
|
|
28
|
+
* closed), so the visible chip never participates in the hydration diff.
|
|
29
|
+
*/
|
|
30
|
+
import { DraftPreviewBadge } from "@decocms/blocks/preview";
|
|
31
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
32
|
+
import { DRAFT_POINTER_BAG_KEY, DRAFT_POINTER_GLOBAL } from "../sdk/draftShared";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The active draft pointer. Server: the request bag (set by `bindRequestDraft`).
|
|
36
|
+
* Client: the bag is a stub → undefined → fall back to the published global.
|
|
37
|
+
*/
|
|
38
|
+
function activePointer(): string | null {
|
|
39
|
+
const fromBag = RequestContext.getBag<string>(DRAFT_POINTER_BAG_KEY);
|
|
40
|
+
if (fromBag) return fromBag;
|
|
41
|
+
if (typeof window !== "undefined") {
|
|
42
|
+
return (window as unknown as Record<string, string | undefined>)[DRAFT_POINTER_GLOBAL] ?? null;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function DraftPreviewIndicator() {
|
|
48
|
+
const pointer = activePointer();
|
|
49
|
+
if (!pointer) return null;
|
|
50
|
+
return (
|
|
51
|
+
<>
|
|
52
|
+
{/* Publishes a JSON-encoded pointer this server resolved (not user
|
|
53
|
+
input) so the client's hydration render matches. See the module doc. */}
|
|
54
|
+
<script
|
|
55
|
+
dangerouslySetInnerHTML={{
|
|
56
|
+
__html: `window.${DRAFT_POINTER_GLOBAL}=${JSON.stringify(pointer)}`,
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
59
|
+
<DraftPreviewBadge pointer={pointer} />
|
|
60
|
+
</>
|
|
61
|
+
);
|
|
62
|
+
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export {
|
|
|
4
4
|
SectionRenderer,
|
|
5
5
|
} from "./DecoPageRenderer";
|
|
6
6
|
export { DecoRootLayout, type DecoRootLayoutProps } from "./DecoRootLayout";
|
|
7
|
+
export { DraftPreviewIndicator } from "./DraftPreviewIndicator";
|
|
7
8
|
export { NavigationProgress } from "./NavigationProgress";
|
|
8
9
|
export { StableOutlet } from "./StableOutlet";
|
|
9
10
|
export { default as PreviewProviders } from "./PreviewProviders";
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clearDraftCache,
|
|
3
|
+
getRequestDraftOverride,
|
|
4
|
+
setDraftPreviewHosts,
|
|
5
|
+
} from "@decocms/blocks/cms";
|
|
6
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
applyDraftCookieAndHeaders,
|
|
11
|
+
bindRequestDraft,
|
|
12
|
+
type DraftDecision,
|
|
13
|
+
installPreviewHostsFromBlocks,
|
|
14
|
+
registerDraftOverride,
|
|
15
|
+
requestCarriesDraft,
|
|
16
|
+
} from "./draft";
|
|
17
|
+
import { DRAFT_POINTER_BAG_KEY } from "./draftShared";
|
|
18
|
+
|
|
19
|
+
const HOST = "preview.example";
|
|
20
|
+
const DRAFT_BLOCKS = { "pages-home": { title: "draft" } };
|
|
21
|
+
|
|
22
|
+
function req(opts: { url?: string; host?: string; cookie?: string } = {}): Request {
|
|
23
|
+
const headers = new Headers();
|
|
24
|
+
headers.set("host", opts.host ?? HOST);
|
|
25
|
+
if (opts.cookie) headers.set("cookie", opts.cookie);
|
|
26
|
+
return new Request(opts.url ?? `https://${opts.host ?? HOST}/some/page`, { headers });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A fetch stub serving the sandbox decofile; throws if `fail`. */
|
|
30
|
+
function sandboxFetch(fail = false): typeof fetch {
|
|
31
|
+
return (async () => {
|
|
32
|
+
if (fail) throw new Error("unreachable");
|
|
33
|
+
return new Response(JSON.stringify(DRAFT_BLOCKS), {
|
|
34
|
+
status: 200,
|
|
35
|
+
headers: { "content-type": "application/json" },
|
|
36
|
+
});
|
|
37
|
+
}) as unknown as typeof fetch;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
clearDraftCache();
|
|
42
|
+
// Allowlist is globalThis-backed (the site block's previewHosts), so no env
|
|
43
|
+
// needed — this makes the feature "enabled" for the tests below.
|
|
44
|
+
setDraftPreviewHosts([HOST, "localhost:3100"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
setDraftPreviewHosts([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("requestCarriesDraft", () => {
|
|
52
|
+
it("is inert when no allowlist is configured", () => {
|
|
53
|
+
setDraftPreviewHosts([]);
|
|
54
|
+
const url = new URL(`https://${HOST}/p?__draft=abc.preview-studio.decocms.com@v1`);
|
|
55
|
+
expect(requestCarriesDraft(req({ url: url.toString() }), url)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("detects the ?__draft= entry param", () => {
|
|
59
|
+
const url = new URL(`https://${HOST}/p?__draft=abc.preview-studio.decocms.com@v1`);
|
|
60
|
+
expect(requestCarriesDraft(req({ url: url.toString() }), url)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("detects the navigation cookie (SPA nav after entry)", () => {
|
|
64
|
+
const url = new URL(`https://${HOST}/p`);
|
|
65
|
+
expect(
|
|
66
|
+
requestCarriesDraft(req({ url: url.toString(), cookie: "__deco_draft=abc.x@v1" }), url),
|
|
67
|
+
).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("is false on ?__draft=off (leaving preview)", () => {
|
|
71
|
+
const url = new URL(`https://${HOST}/p?__draft=off`);
|
|
72
|
+
expect(
|
|
73
|
+
requestCarriesDraft(req({ url: url.toString(), cookie: "__deco_draft=abc.x@v1" }), url),
|
|
74
|
+
).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("is structurally inert on a non-preview host, param or not", () => {
|
|
78
|
+
const url = new URL("https://prod.example/p?__draft=abc.x@v1");
|
|
79
|
+
expect(requestCarriesDraft(req({ url: url.toString(), host: "prod.example" }), url)).toBe(
|
|
80
|
+
false,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("bindRequestDraft", () => {
|
|
86
|
+
it("binds the resolved draft to the request bag on entry", async () => {
|
|
87
|
+
registerDraftOverride();
|
|
88
|
+
const pointer = "abc.preview-studio.decocms.com@v1";
|
|
89
|
+
const request = req({ url: `https://${HOST}/p?__draft=${pointer}` });
|
|
90
|
+
|
|
91
|
+
const decision = await RequestContext.run(request, async () => {
|
|
92
|
+
const d = await bindRequestDraft(request, sandboxFetch());
|
|
93
|
+
// loadBlocks() reads this back via the DI getter registered above.
|
|
94
|
+
expect(getRequestDraftOverride()).toEqual(DRAFT_BLOCKS);
|
|
95
|
+
return d;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(decision).toEqual({ previewing: true, setCookie: pointer, clearCookie: false });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("carries the draft across navigation via the cookie (no re-set)", async () => {
|
|
102
|
+
registerDraftOverride();
|
|
103
|
+
const pointer = "abc.preview-studio.decocms.com@v1";
|
|
104
|
+
const request = req({ url: `https://${HOST}/other`, cookie: `__deco_draft=${pointer}` });
|
|
105
|
+
|
|
106
|
+
const decision = await RequestContext.run(request, () =>
|
|
107
|
+
bindRequestDraft(request, sandboxFetch()),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// previewing, but setCookie is null — the cookie is already present.
|
|
111
|
+
expect(decision).toEqual({ previewing: true, setCookie: null, clearCookie: false });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("decodes a URL-encoded cookie so the badge pointer round-trips (no double-encode)", async () => {
|
|
115
|
+
// The cookie is written with encodeURIComponent (`:`→%3A, `@`→%40); the
|
|
116
|
+
// pointer stashed for the badge must be the DECODED value, or the badge's
|
|
117
|
+
// share link would re-encode an already-encoded string.
|
|
118
|
+
registerDraftOverride();
|
|
119
|
+
const decoded = "abc.localhost:60534@v7";
|
|
120
|
+
const request = req({
|
|
121
|
+
url: `https://${HOST}/other`,
|
|
122
|
+
cookie: `__deco_draft=${encodeURIComponent(decoded)}`,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const stored = await RequestContext.run(request, async () => {
|
|
126
|
+
await bindRequestDraft(request, sandboxFetch());
|
|
127
|
+
return RequestContext.getBag<string>(DRAFT_POINTER_BAG_KEY);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(stored).toBe(decoded);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("stays previewing (uncacheable) even when the draft fails to resolve", async () => {
|
|
134
|
+
const request = req({ url: `https://${HOST}/p?__draft=abc.preview-studio.decocms.com@v1` });
|
|
135
|
+
const decision = await RequestContext.run(request, () =>
|
|
136
|
+
bindRequestDraft(request, sandboxFetch(true)),
|
|
137
|
+
);
|
|
138
|
+
expect(decision.previewing).toBe(true);
|
|
139
|
+
expect(decision.setCookie).toBe("abc.preview-studio.decocms.com@v1");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("clears the cookie on ?__draft=off", async () => {
|
|
143
|
+
const request = req({ url: `https://${HOST}/p?__draft=off`, cookie: "__deco_draft=x@v1" });
|
|
144
|
+
const decision = await RequestContext.run(request, () => bindRequestDraft(request));
|
|
145
|
+
expect(decision).toEqual({ previewing: false, setCookie: null, clearCookie: true });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("is inert on a non-preview host", async () => {
|
|
149
|
+
const request = req({
|
|
150
|
+
url: "https://prod.example/p?__draft=abc.x@v1",
|
|
151
|
+
host: "prod.example",
|
|
152
|
+
});
|
|
153
|
+
const decision = await RequestContext.run(request, () => bindRequestDraft(request));
|
|
154
|
+
expect(decision).toEqual({ previewing: false, setCookie: null, clearCookie: false });
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("applyDraftCookieAndHeaders", () => {
|
|
159
|
+
const entry: DraftDecision = {
|
|
160
|
+
previewing: true,
|
|
161
|
+
setCookie: "abc@v1",
|
|
162
|
+
clearCookie: false,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
it("sets a cross-site-iframe-safe cookie and the anti-leak headers on entry", () => {
|
|
166
|
+
const res = new Response("ok");
|
|
167
|
+
applyDraftCookieAndHeaders(res, entry);
|
|
168
|
+
|
|
169
|
+
const cookie = res.headers.get("set-cookie") ?? "";
|
|
170
|
+
expect(cookie).toContain("__deco_draft=abc%40v1"); // pointer is URL-encoded
|
|
171
|
+
expect(cookie).toContain("SameSite=None");
|
|
172
|
+
expect(cookie).toContain("Secure");
|
|
173
|
+
expect(cookie).toContain("Partitioned");
|
|
174
|
+
expect(cookie).toContain("HttpOnly");
|
|
175
|
+
|
|
176
|
+
expect(res.headers.get("cache-control")).toBe("no-store, private");
|
|
177
|
+
expect(res.headers.get("vary")).toBe("Cookie");
|
|
178
|
+
expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("expires the cookie when leaving preview", () => {
|
|
182
|
+
const res = new Response("ok");
|
|
183
|
+
applyDraftCookieAndHeaders(res, { previewing: false, setCookie: null, clearCookie: true });
|
|
184
|
+
expect(res.headers.get("set-cookie") ?? "").toContain("Max-Age=0");
|
|
185
|
+
// Not previewing → no cache/index headers.
|
|
186
|
+
expect(res.headers.get("cache-control")).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("merges Cookie into an existing Vary instead of clobbering it", () => {
|
|
190
|
+
const res = new Response("ok", { headers: { Vary: "Accept-Encoding" } });
|
|
191
|
+
applyDraftCookieAndHeaders(res, { previewing: true, setCookie: null, clearCookie: false });
|
|
192
|
+
expect(res.headers.get("vary")).toBe("Accept-Encoding, Cookie");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("leaves a Vary that already lists Cookie untouched (no token dropped)", () => {
|
|
196
|
+
const res = new Response("ok", { headers: { Vary: "Accept-Encoding, Cookie" } });
|
|
197
|
+
applyDraftCookieAndHeaders(res, { previewing: true, setCookie: null, clearCookie: false });
|
|
198
|
+
expect(res.headers.get("vary")).toBe("Accept-Encoding, Cookie");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("does nothing for an ordinary (non-draft) response", () => {
|
|
202
|
+
const res = new Response("ok");
|
|
203
|
+
applyDraftCookieAndHeaders(res, { previewing: false, setCookie: null, clearCookie: false });
|
|
204
|
+
expect(res.headers.get("set-cookie")).toBeNull();
|
|
205
|
+
expect(res.headers.get("cache-control")).toBeNull();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("installPreviewHostsFromBlocks", () => {
|
|
210
|
+
it("installs previewHosts from the site block (either casing)", async () => {
|
|
211
|
+
setDraftPreviewHosts([]);
|
|
212
|
+
installPreviewHostsFromBlocks({ Site: { previewHosts: ["fila.vtex.app"] } });
|
|
213
|
+
|
|
214
|
+
// Verified through the wire: an allowed host now previews.
|
|
215
|
+
const request = req({
|
|
216
|
+
url: "https://fila.vtex.app/p?__draft=abc.preview-studio.decocms.com@v1",
|
|
217
|
+
host: "fila.vtex.app",
|
|
218
|
+
});
|
|
219
|
+
const url = new URL(request.url);
|
|
220
|
+
expect(requestCarriesDraft(request, url)).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("ignores blocks without previewHosts", () => {
|
|
224
|
+
setDraftPreviewHosts([]);
|
|
225
|
+
installPreviewHostsFromBlocks({ site: {} });
|
|
226
|
+
const url = new URL("https://fila.vtex.app/p?__draft=x@v1");
|
|
227
|
+
expect(requestCarriesDraft(req({ url: url.toString(), host: "fila.vtex.app" }), url)).toBe(
|
|
228
|
+
false,
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
});
|
package/src/sdk/draft.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draft preview — the TanStack Start / Cloudflare Workers binding.
|
|
3
|
+
*
|
|
4
|
+
* `@decocms/blocks`'s `draftSource` owns the framework-agnostic half (token
|
|
5
|
+
* parsing, origin validation, fetch, version cache, the host allowlist). This
|
|
6
|
+
* file binds a resolved draft to the current request and drives the cookie +
|
|
7
|
+
* cache/indexing headers.
|
|
8
|
+
*
|
|
9
|
+
* ## Why this is simpler than the Next.js binding
|
|
10
|
+
*
|
|
11
|
+
* The Next binding (`@decocms/nextjs`'s `draft.ts`/`draftMiddleware.ts`) fights
|
|
12
|
+
* two App Router constraints that do NOT exist here:
|
|
13
|
+
*
|
|
14
|
+
* 1. **Request scope.** App Router never enters an AsyncLocalStorage request
|
|
15
|
+
* scope, so Next backs the per-request draft slot with React `cache()` and
|
|
16
|
+
* has to await `ensureDraft` from the *page* (a layout's await does not gate
|
|
17
|
+
* its children). TanStack already wraps every request in
|
|
18
|
+
* `RequestContext.run(request, …)` (see `workerEntry.ts`), so the draft just
|
|
19
|
+
* rides the request bag — `loadBlocks()` reads it back through the
|
|
20
|
+
* dependency-injected getter registered by {@link registerDraftOverride}.
|
|
21
|
+
*
|
|
22
|
+
* 2. **Static-vs-dynamic rewrite.** `dynamic`/`revalidate` are static route
|
|
23
|
+
* exports in App Router, so Next rewrites drafted requests onto an
|
|
24
|
+
* encoded `app/%5Fdraft/…` route to escape ISR. The worker decides
|
|
25
|
+
* cacheability per request, so a drafted request simply bypasses the edge
|
|
26
|
+
* cache (see `requestCarriesDraft`) — no rewrite dance.
|
|
27
|
+
*
|
|
28
|
+
* Like Next, the whole feature is inert unless the site opts in: an allowlist
|
|
29
|
+
* (the site block's `previewHosts`, or the `DECO_ALLOWED_PREVIEW_HOSTS`
|
|
30
|
+
* override) must name the request's host. Upgrading the package can never be
|
|
31
|
+
* enough to start rendering unpublished content.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
DRAFT_COOKIE_NAME,
|
|
36
|
+
DRAFT_QUERY_PARAM,
|
|
37
|
+
isDraftHostAllowed,
|
|
38
|
+
isDraftPreviewEnabled,
|
|
39
|
+
resolveDraftForRequest,
|
|
40
|
+
setDraftOverrideGetter,
|
|
41
|
+
setDraftPreviewHosts,
|
|
42
|
+
} from "@decocms/blocks/cms";
|
|
43
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
44
|
+
import { DRAFT_POINTER_BAG_KEY } from "./draftShared";
|
|
45
|
+
|
|
46
|
+
/** RequestContext bag key the resolved draft decofile is stashed under. */
|
|
47
|
+
const DRAFT_BAG_KEY = "deco:draftBlocks";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Cookie lifetime. Short on purpose: a preview session is minutes, and a stale
|
|
51
|
+
* pointer would keep pinning an old content version. Matches the Next binding.
|
|
52
|
+
*/
|
|
53
|
+
const DRAFT_COOKIE_MAX_AGE = 60 * 30;
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Runtime binding (dependency injection into @decocms/blocks)
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
let registered = false;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Register the request-scoped draft getter with the runtime, once.
|
|
63
|
+
*
|
|
64
|
+
* `loadBlocks()` consults `getRequestDraftOverride()` as a fallback after its
|
|
65
|
+
* own `withBlocksOverride` scope; this points that fallback at the
|
|
66
|
+
* RequestContext bag {@link bindRequestDraft} fills. Idempotent and safe to
|
|
67
|
+
* call at setup: outside a request scope `getBag` returns undefined, so
|
|
68
|
+
* `loadBlocks()` behaves exactly as before.
|
|
69
|
+
*/
|
|
70
|
+
export function registerDraftOverride(): void {
|
|
71
|
+
if (registered) return;
|
|
72
|
+
registered = true;
|
|
73
|
+
setDraftOverrideGetter(
|
|
74
|
+
() => RequestContext.getBag<Record<string, unknown>>(DRAFT_BAG_KEY) ?? null,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Install the site block's `previewHosts` as the draft-preview allowlist.
|
|
80
|
+
*
|
|
81
|
+
* Reads the BASE blocks (the `setBlocks()` argument / `loadBlocks()` at setup
|
|
82
|
+
* time), never the request-time merged blocks: an allowlist readable through
|
|
83
|
+
* the draft override could be rewritten by the very draft it gates.
|
|
84
|
+
* `DECO_ALLOWED_PREVIEW_HOSTS` remains an operational override that replaces
|
|
85
|
+
* this list when set. Handles both decofile shapes (`site` / `Site`), same as
|
|
86
|
+
* `withSiteGlobals`.
|
|
87
|
+
*/
|
|
88
|
+
export function installPreviewHostsFromBlocks(blocks: Record<string, unknown> | undefined): void {
|
|
89
|
+
const site = (blocks?.site ?? blocks?.Site) as { previewHosts?: unknown } | undefined;
|
|
90
|
+
if (Array.isArray(site?.previewHosts)) setDraftPreviewHosts(site.previewHosts);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Per-request resolution + response decoration
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Read one cookie value out of a raw `Cookie:` header.
|
|
99
|
+
*
|
|
100
|
+
* Decodes the value — the cookie is written with `encodeURIComponent` (see
|
|
101
|
+
* `draftCookie`), and `@decocms/blocks`'s own cookie reader decodes too, so a
|
|
102
|
+
* cookie-carried pointer must round-trip identically or the badge's share link
|
|
103
|
+
* would double-encode it. Decode defensively: a malformed `%` sequence
|
|
104
|
+
* (a crafted cookie) falls back to the raw value rather than throwing and
|
|
105
|
+
* 500-ing the request.
|
|
106
|
+
*/
|
|
107
|
+
function readCookie(cookieHeader: string | null, name: string): string | null {
|
|
108
|
+
if (!cookieHeader) return null;
|
|
109
|
+
for (const part of cookieHeader.split(";")) {
|
|
110
|
+
const eq = part.indexOf("=");
|
|
111
|
+
if (eq === -1) continue;
|
|
112
|
+
if (part.slice(0, eq).trim() === name) {
|
|
113
|
+
const raw = part.slice(eq + 1).trim();
|
|
114
|
+
try {
|
|
115
|
+
return decodeURIComponent(raw);
|
|
116
|
+
} catch {
|
|
117
|
+
return raw;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether a request is in draft mode — carries a live `?__draft=` param or the
|
|
126
|
+
* navigation cookie — on a host allowed to preview.
|
|
127
|
+
*
|
|
128
|
+
* A pure, synchronous, network-free check (no fetch, no resolve): the worker
|
|
129
|
+
* uses it to keep drafted requests out of the shared edge cache *before* doing
|
|
130
|
+
* any work. Gated on `isDraftPreviewEnabled()` first, so a build without the
|
|
131
|
+
* allowlist configured is completely unaffected — a stray `?__draft=` on a
|
|
132
|
+
* production domain neither renders a draft nor even bypasses the cache.
|
|
133
|
+
*/
|
|
134
|
+
export function requestCarriesDraft(request: Request, url: URL): boolean {
|
|
135
|
+
if (!isDraftPreviewEnabled()) return false;
|
|
136
|
+
const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
|
137
|
+
if (!isDraftHostAllowed(host)) return false;
|
|
138
|
+
const param = url.searchParams.get(DRAFT_QUERY_PARAM);
|
|
139
|
+
if (param === "off") return false;
|
|
140
|
+
if (param) return true;
|
|
141
|
+
return readCookie(request.headers.get("cookie"), DRAFT_COOKIE_NAME) !== null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The cookie/header actions a drafted request implies for its response. */
|
|
145
|
+
export interface DraftDecision {
|
|
146
|
+
/**
|
|
147
|
+
* The request is previewing (a pointer is present on an allowed host), so its
|
|
148
|
+
* response must never be cached under the shared (cookie-only) URL — even if
|
|
149
|
+
* the draft failed to resolve and the page fell back to published content.
|
|
150
|
+
*/
|
|
151
|
+
previewing: boolean;
|
|
152
|
+
/** Persist this pointer in the navigation cookie (set only on `?__draft=` entry). */
|
|
153
|
+
setCookie: string | null;
|
|
154
|
+
/** Clear the navigation cookie (`?__draft=off`). */
|
|
155
|
+
clearCookie: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const INERT: DraftDecision = { previewing: false, setCookie: null, clearCookie: false };
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolve this request's draft, if any, and bind it to the RequestContext so
|
|
162
|
+
* every `loadBlocks()` in the render sees the merged decofile.
|
|
163
|
+
*
|
|
164
|
+
* Call inside `RequestContext.run`, before the page render. Returns the
|
|
165
|
+
* cookie/header decision for the worker to apply to the final response. A
|
|
166
|
+
* missing allowlist, disallowed host, or `?__draft=off` all short-circuit
|
|
167
|
+
* without touching the network; a resolve failure degrades to published but
|
|
168
|
+
* still reports `previewing: true` so the response stays uncacheable.
|
|
169
|
+
*/
|
|
170
|
+
export async function bindRequestDraft(
|
|
171
|
+
request: Request,
|
|
172
|
+
fetchImpl?: typeof fetch,
|
|
173
|
+
): Promise<DraftDecision> {
|
|
174
|
+
if (!isDraftPreviewEnabled()) return INERT;
|
|
175
|
+
|
|
176
|
+
const url = new URL(request.url);
|
|
177
|
+
const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
|
178
|
+
// Authoritative host gate — unlike Next, there is no separate middleware
|
|
179
|
+
// module graph, so this single check bounds the whole feature.
|
|
180
|
+
if (!isDraftHostAllowed(host)) return INERT;
|
|
181
|
+
|
|
182
|
+
const param = url.searchParams.get(DRAFT_QUERY_PARAM);
|
|
183
|
+
if (param === "off") return { previewing: false, setCookie: null, clearCookie: true };
|
|
184
|
+
|
|
185
|
+
const cookie = readCookie(request.headers.get("cookie"), DRAFT_COOKIE_NAME);
|
|
186
|
+
const pointer = param ?? cookie ?? null;
|
|
187
|
+
if (!pointer) return INERT;
|
|
188
|
+
|
|
189
|
+
const blocks = await resolveDraftForRequest(request, { fetchImpl });
|
|
190
|
+
if (blocks) {
|
|
191
|
+
RequestContext.setBag(DRAFT_BAG_KEY, blocks);
|
|
192
|
+
// Expose the pointer to the SSR React tree so the preview badge can render
|
|
193
|
+
// and build its share/exit links. Only when the draft actually bound —
|
|
194
|
+
// the badge means "you're looking at draft content", matching Next.
|
|
195
|
+
RequestContext.setBag(DRAFT_POINTER_BAG_KEY, pointer);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Persist only on entry (`?__draft=`); in-preview navigation rides the cookie
|
|
199
|
+
// already present.
|
|
200
|
+
return { previewing: true, setCookie: param ?? null, clearCookie: false };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Build the `Set-Cookie` value for the draft navigation cookie. */
|
|
204
|
+
function draftCookie(value: string, maxAge: number): string {
|
|
205
|
+
// SameSite=None; Secure is mandatory for the cross-site preview iframe, and
|
|
206
|
+
// Partitioned (CHIPS) keeps it working as browsers wind down unpartitioned
|
|
207
|
+
// third-party cookies. HttpOnly: the pointer is read server-side only.
|
|
208
|
+
return `${DRAFT_COOKIE_NAME}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=None; Partitioned`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Set a header defensively — some replayed responses seal their headers. */
|
|
212
|
+
function trySet(response: Response, key: string, value: string): void {
|
|
213
|
+
try {
|
|
214
|
+
response.headers.set(key, value);
|
|
215
|
+
} catch {
|
|
216
|
+
/* sealed headers (cached replay) — best-effort */
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Apply the cookie lifecycle and the cache/indexing headers a draft response
|
|
222
|
+
* requires.
|
|
223
|
+
*
|
|
224
|
+
* The caching headers are the difference between a preview and a leak: with the
|
|
225
|
+
* pointer in a cookie, a drafted response and a published one share an
|
|
226
|
+
* identical URL, so `no-store` is what stops a CDN keyed on URL alone from
|
|
227
|
+
* serving unpublished content to a real visitor. `Vary: Cookie` keeps any
|
|
228
|
+
* intermediary that does respect it from mixing the two; `X-Robots-Tag` keeps a
|
|
229
|
+
* leaked draft out of search results.
|
|
230
|
+
*/
|
|
231
|
+
export function applyDraftCookieAndHeaders(response: Response, decision: DraftDecision): void {
|
|
232
|
+
if (decision.clearCookie) {
|
|
233
|
+
response.headers.append("set-cookie", draftCookie("", 0));
|
|
234
|
+
} else if (decision.setCookie) {
|
|
235
|
+
response.headers.append(
|
|
236
|
+
"set-cookie",
|
|
237
|
+
draftCookie(encodeURIComponent(decision.setCookie), DRAFT_COOKIE_MAX_AGE),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (decision.previewing) {
|
|
242
|
+
trySet(response, "cache-control", "no-store, private");
|
|
243
|
+
// Add Cookie to Vary without dropping tokens already there (e.g.
|
|
244
|
+
// Accept-Encoding). Moot under no-store, but correct for any intermediary
|
|
245
|
+
// that honours Vary anyway.
|
|
246
|
+
const existingVary = response.headers.get("vary");
|
|
247
|
+
if (!existingVary) {
|
|
248
|
+
trySet(response, "vary", "Cookie");
|
|
249
|
+
} else if (!/\bcookie\b/i.test(existingVary)) {
|
|
250
|
+
trySet(response, "vary", `${existingVary}, Cookie`);
|
|
251
|
+
}
|
|
252
|
+
trySet(response, "x-robots-tag", "noindex, nofollow");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-bundle-safe constants shared by the server-side draft binding
|
|
3
|
+
* (`./draft`, which imports the server-only `@decocms/blocks/cms`) and the
|
|
4
|
+
* isomorphic preview-badge indicator (`../hooks/DraftPreviewIndicator`, which
|
|
5
|
+
* must stay client-safe). Kept in their own module so the indicator never has
|
|
6
|
+
* to import `./draft` and drag `node:async_hooks` into the browser bundle.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** RequestContext bag key the active draft pointer is stashed under during SSR. */
|
|
10
|
+
export const DRAFT_POINTER_BAG_KEY = "deco:draftPointer";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Global the SSR pass publishes the pointer on, so the client's hydration
|
|
14
|
+
* render reads the same value (RequestContext is a no-op stub in the browser).
|
|
15
|
+
*/
|
|
16
|
+
export const DRAFT_POINTER_GLOBAL = "__DECO_DRAFT_POINTER__";
|
|
@@ -493,3 +493,113 @@ describe("X-Cache-Segment — custom SegmentKey fields (#284)", () => {
|
|
|
493
493
|
expect(res.headers.get("X-Cache-Segment")).toBe("desktop|sc=1|f=a,b");
|
|
494
494
|
});
|
|
495
495
|
});
|
|
496
|
+
|
|
497
|
+
describe("draft preview (pull-based)", () => {
|
|
498
|
+
const POINTER = "abc.localhost:1234@v1";
|
|
499
|
+
const SANDBOX_URL = "http://abc.localhost:1234/_sandbox/decofile";
|
|
500
|
+
const DRAFT_BLOCKS = { "pages-home": { name: "home", path: "/", title: "DRAFTED" } };
|
|
501
|
+
|
|
502
|
+
// A serverEntry that reflects the currently-loaded home title, so a test can
|
|
503
|
+
// prove the draft override actually reached the render (not just the headers).
|
|
504
|
+
const REFLECT_ENTRY = {
|
|
505
|
+
fetch: async () => {
|
|
506
|
+
const { loadBlocks } = await import("@decocms/blocks/cms");
|
|
507
|
+
const home = (loadBlocks()["pages-home"] as { title?: string } | undefined) ?? {};
|
|
508
|
+
return new Response(home.title ?? "PUBLISHED", { status: 200 });
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
let realFetch: typeof fetch;
|
|
513
|
+
afterEach(async () => {
|
|
514
|
+
globalThis.fetch = realFetch;
|
|
515
|
+
const { setDraftPreviewHosts, clearDraftCache } = await import("@decocms/blocks/cms");
|
|
516
|
+
setDraftPreviewHosts([]);
|
|
517
|
+
clearDraftCache();
|
|
518
|
+
__resetKvHydrationStateForTests();
|
|
519
|
+
setBlocks({});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
async function enable() {
|
|
523
|
+
const { setDraftPreviewHosts, clearDraftCache } = await import("@decocms/blocks/cms");
|
|
524
|
+
setBlocks({ "pages-home": { name: "home", path: "/", title: "PUBLISHED" } });
|
|
525
|
+
setDraftPreviewHosts(["example.com"]);
|
|
526
|
+
clearDraftCache();
|
|
527
|
+
realFetch = globalThis.fetch;
|
|
528
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
529
|
+
if (String(input) === SANDBOX_URL) {
|
|
530
|
+
return new Response(JSON.stringify(DRAFT_BLOCKS), {
|
|
531
|
+
status: 200,
|
|
532
|
+
headers: { "content-type": "application/json" },
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
throw new Error(`unexpected fetch: ${String(input)}`);
|
|
536
|
+
}) as unknown as typeof fetch;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
it("binds the draft, bypasses the edge cache, and stamps the anti-leak headers", async () => {
|
|
540
|
+
await enable();
|
|
541
|
+
const worker = createDecoWorkerEntry(REFLECT_ENTRY, { observability: false });
|
|
542
|
+
const res = await worker.fetch(
|
|
543
|
+
new Request(`https://example.com/?__draft=${POINTER}`, {
|
|
544
|
+
headers: { host: "example.com" },
|
|
545
|
+
}),
|
|
546
|
+
EMPTY_ENV,
|
|
547
|
+
MOCK_CTX,
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
// The render saw the merged draft decofile.
|
|
551
|
+
expect(await res.text()).toBe("DRAFTED");
|
|
552
|
+
// Never cached under the shared URL.
|
|
553
|
+
expect(res.headers.get("X-Cache")).toBe("BYPASS");
|
|
554
|
+
expect(res.headers.get("Cache-Control")).toContain("no-store");
|
|
555
|
+
expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
|
556
|
+
// Entry persists the navigation cookie.
|
|
557
|
+
const cookie = res.headers.get("set-cookie") ?? "";
|
|
558
|
+
expect(cookie).toContain("__deco_draft=");
|
|
559
|
+
expect(cookie).toContain("SameSite=None");
|
|
560
|
+
expect(cookie).toContain("Partitioned");
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("carries the draft across navigation via the cookie alone", async () => {
|
|
564
|
+
await enable();
|
|
565
|
+
const worker = createDecoWorkerEntry(REFLECT_ENTRY, { observability: false });
|
|
566
|
+
const res = await worker.fetch(
|
|
567
|
+
new Request("https://example.com/other", {
|
|
568
|
+
headers: { host: "example.com", cookie: `__deco_draft=${POINTER}` },
|
|
569
|
+
}),
|
|
570
|
+
EMPTY_ENV,
|
|
571
|
+
MOCK_CTX,
|
|
572
|
+
);
|
|
573
|
+
expect(await res.text()).toBe("DRAFTED");
|
|
574
|
+
expect(res.headers.get("X-Cache")).toBe("BYPASS");
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("is structurally inert on a non-preview host", async () => {
|
|
578
|
+
await enable(); // allowlist = example.com only
|
|
579
|
+
const worker = createDecoWorkerEntry(REFLECT_ENTRY, { observability: false });
|
|
580
|
+
const res = await worker.fetch(
|
|
581
|
+
new Request(`https://prod.example/?__draft=${POINTER}`, {
|
|
582
|
+
headers: { host: "prod.example" },
|
|
583
|
+
}),
|
|
584
|
+
EMPTY_ENV,
|
|
585
|
+
MOCK_CTX,
|
|
586
|
+
);
|
|
587
|
+
expect(await res.text()).toBe("PUBLISHED");
|
|
588
|
+
expect(res.headers.get("X-Robots-Tag")).toBeNull();
|
|
589
|
+
expect(res.headers.get("set-cookie")).toBeNull();
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it("stays inert on any host when no allowlist is configured", async () => {
|
|
593
|
+
setBlocks({ "pages-home": { name: "home", path: "/", title: "PUBLISHED" } });
|
|
594
|
+
const worker = createDecoWorkerEntry(REFLECT_ENTRY, { observability: false });
|
|
595
|
+
const res = await worker.fetch(
|
|
596
|
+
new Request(`https://example.com/?__draft=${POINTER}`, {
|
|
597
|
+
headers: { host: "example.com" },
|
|
598
|
+
}),
|
|
599
|
+
EMPTY_ENV,
|
|
600
|
+
MOCK_CTX,
|
|
601
|
+
);
|
|
602
|
+
expect(await res.text()).toBe("PUBLISHED");
|
|
603
|
+
expect(res.headers.get("X-Robots-Tag")).toBeNull();
|
|
604
|
+
});
|
|
605
|
+
});
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -71,6 +71,14 @@ import { type Device, isMobileUA } from "@decocms/blocks/sdk/useDevice";
|
|
|
71
71
|
import { getRenderShellConfig } from "@decocms/blocks-admin/admin/setup";
|
|
72
72
|
import { buildHtmlShell } from "@decocms/blocks-admin/sdk/htmlShell";
|
|
73
73
|
import { getAppMiddleware } from "@decocms/blocks-admin/sdk/setupApps";
|
|
74
|
+
import {
|
|
75
|
+
applyDraftCookieAndHeaders,
|
|
76
|
+
bindRequestDraft,
|
|
77
|
+
type DraftDecision,
|
|
78
|
+
installPreviewHostsFromBlocks,
|
|
79
|
+
registerDraftOverride,
|
|
80
|
+
requestCarriesDraft,
|
|
81
|
+
} from "./draft";
|
|
74
82
|
import { ensureBlocksHydrated, maybePollRevision } from "./kvHydration";
|
|
75
83
|
import { DECO_POWERED_BY, installDefaultUserAgent } from "./outboundHeaders";
|
|
76
84
|
|
|
@@ -803,6 +811,22 @@ function installAutoGeoListener(): void {
|
|
|
803
811
|
update(loadBlocks());
|
|
804
812
|
}
|
|
805
813
|
|
|
814
|
+
let _draftPreviewInstalled = false;
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Wire draft preview once: register the request-scoped draft getter with the
|
|
818
|
+
* runtime and keep the `previewHosts` allowlist in sync with the base decofile.
|
|
819
|
+
* Registered unconditionally (idempotent), but the feature stays fully inert
|
|
820
|
+
* until an allowlist names the request's host — see `./draft`.
|
|
821
|
+
*/
|
|
822
|
+
function installDraftPreviewListener(): void {
|
|
823
|
+
if (_draftPreviewInstalled) return;
|
|
824
|
+
_draftPreviewInstalled = true;
|
|
825
|
+
registerDraftOverride();
|
|
826
|
+
onChange((blocks) => installPreviewHostsFromBlocks(blocks));
|
|
827
|
+
installPreviewHostsFromBlocks(loadBlocks());
|
|
828
|
+
}
|
|
829
|
+
|
|
806
830
|
const IMMUTABLE_HEADERS: Record<string, string> = {
|
|
807
831
|
"Cache-Control": `public, max-age=${ONE_YEAR}, immutable`,
|
|
808
832
|
Vary: "Accept-Encoding",
|
|
@@ -883,6 +907,10 @@ export function createDecoWorkerEntry(
|
|
|
883
907
|
installAutoGeoListener();
|
|
884
908
|
}
|
|
885
909
|
|
|
910
|
+
// Draft preview (pull-based): getter DI + previewHosts sync. Inert unless
|
|
911
|
+
// the site opts in via `previewHosts` / DECO_ALLOWED_PREVIEW_HOSTS.
|
|
912
|
+
installDraftPreviewListener();
|
|
913
|
+
|
|
886
914
|
/** Resolve the effective geo granularity for the current request. */
|
|
887
915
|
function effectiveGeoKey(): "off" | "country" | "region" | "city" {
|
|
888
916
|
return geoCacheKeyOpt === "auto" ? _autoGeoKey : geoCacheKeyOpt;
|
|
@@ -963,7 +991,10 @@ export function createDecoWorkerEntry(
|
|
|
963
991
|
function isCacheable(request: Request, url: URL): boolean {
|
|
964
992
|
if (request.method !== "GET") return false;
|
|
965
993
|
if (isBypassPath(url.pathname)) return false;
|
|
966
|
-
|
|
994
|
+
// Draft preview: never cache a request in draft mode. Keys on the real
|
|
995
|
+
// `?__draft=` param OR the navigation cookie (SPA nav after entry), gated
|
|
996
|
+
// on an allowed preview host — see requestCarriesDraft.
|
|
997
|
+
if (requestCarriesDraft(request, url)) return false;
|
|
967
998
|
if (url.searchParams.has("__deco_preview")) return false;
|
|
968
999
|
if (url.searchParams.has("pathTemplate")) return false;
|
|
969
1000
|
// Forced matcher results must never be served from (or stored in) the
|
|
@@ -1452,6 +1483,12 @@ export function createDecoWorkerEntry(
|
|
|
1452
1483
|
// become no-ops.
|
|
1453
1484
|
const identity = { requestId: "", traceId: "" };
|
|
1454
1485
|
|
|
1486
|
+
// Draft-preview decision — resolved inside RequestContext.run (below) so
|
|
1487
|
+
// the resolved decofile binds to the request bag before the render, then
|
|
1488
|
+
// lifted here so the cookie + no-store/noindex headers can be stamped on
|
|
1489
|
+
// the final response. Inert on non-preview builds/hosts.
|
|
1490
|
+
let draftDecision: DraftDecision = { previewing: false, setCookie: null, clearCookie: false };
|
|
1491
|
+
|
|
1455
1492
|
// Wrap the entire request in a RequestContext so that all code
|
|
1456
1493
|
// in the call stack (loaders, invoke handlers, vtexFetchWithCookies)
|
|
1457
1494
|
// can access the request and write response headers.
|
|
@@ -1480,6 +1517,11 @@ export function createDecoWorkerEntry(
|
|
|
1480
1517
|
// production request without changing global sampling rates.
|
|
1481
1518
|
if (new URL(request.url).searchParams.has("__d")) _setDebugSampled();
|
|
1482
1519
|
|
|
1520
|
+
// Resolve + bind any draft preview BEFORE the render, so every
|
|
1521
|
+
// loadBlocks() in the call stack sees the merged draft decofile via
|
|
1522
|
+
// the request bag. Cheap no-op unless the site opts in.
|
|
1523
|
+
draftDecision = await bindRequestDraft(request);
|
|
1524
|
+
|
|
1483
1525
|
// Wrap inner handler in a single root span carrying our normalized
|
|
1484
1526
|
// path/method attributes. The framework span flows BOTH ways:
|
|
1485
1527
|
// - via the OTLP direct-POST tracer (when DECO_OTEL_TRACES_ENDPOINT
|
|
@@ -1591,6 +1633,11 @@ export function createDecoWorkerEntry(
|
|
|
1591
1633
|
}
|
|
1592
1634
|
}
|
|
1593
1635
|
|
|
1636
|
+
// Draft preview: persist/clear the navigation cookie and stamp the
|
|
1637
|
+
// no-store/noindex headers so a drafted response is never cached under
|
|
1638
|
+
// its shared (cookie-only) URL. No-op when not previewing.
|
|
1639
|
+
applyDraftCookieAndHeaders(finalResponse, draftDecision);
|
|
1640
|
+
|
|
1594
1641
|
// Metrics + structured request log. Done after security headers so
|
|
1595
1642
|
// the recorded status reflects what the client actually receives.
|
|
1596
1643
|
// Both calls are no-ops when no meter / logger is configured.
|
|
@@ -1829,6 +1876,20 @@ export function createDecoWorkerEntry(
|
|
|
1829
1876
|
? ((caches as unknown as { default?: Cache }).default ?? null)
|
|
1830
1877
|
: null;
|
|
1831
1878
|
|
|
1879
|
+
// Draft preview: a drafted SPA navigation fetches its section data via
|
|
1880
|
+
// this POST, carrying the draft cookie. The draft is already bound to the
|
|
1881
|
+
// request bag (bindRequestDraft, above), so origin renders it — but the
|
|
1882
|
+
// body-hash cache key does not vary on the cookie, so it must bypass the
|
|
1883
|
+
// shared cache entirely or it would leak drafts / serve stale published.
|
|
1884
|
+
if (requestCarriesDraft(request, url)) {
|
|
1885
|
+
const origin = await serverEntry.fetch(request, env, ctx);
|
|
1886
|
+
const resp = new Response(origin.body, origin);
|
|
1887
|
+
resp.headers.set("Cache-Control", "no-store, private");
|
|
1888
|
+
resp.headers.set("X-Cache", "BYPASS");
|
|
1889
|
+
resp.headers.set("X-Cache-Reason", "draft-preview");
|
|
1890
|
+
return resp;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1832
1893
|
// Build segment once — used for logged-in check and cache key
|
|
1833
1894
|
const sfnSegment = buildSegment ? buildSegment(request) : undefined;
|
|
1834
1895
|
|