@decocms/tanstack 8.0.0 → 8.1.0-next.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 +12 -5
- package/src/__conformance__/observability.test.ts +221 -0
- package/src/hooks/CdnSegmentMarker.tsx +38 -0
- package/src/hooks/DecoPageRenderer.tsx +91 -29
- package/src/hooks/DecoRootLayout.tsx +139 -58
- package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
- package/src/hooks/DraftPreviewIndicator.tsx +64 -0
- package/src/hooks/NavigationProgress.test.tsx +58 -0
- package/src/hooks/NavigationProgress.tsx +45 -3
- package/src/hooks/PreviewProviders.test.tsx +42 -0
- package/src/hooks/PreviewProviders.tsx +21 -2
- package/src/hooks/deferredObserverTiming.test.tsx +91 -0
- package/src/hooks/index.ts +1 -0
- package/src/index.ts +30 -13
- package/src/routes/cmsRoute.test.ts +168 -1
- package/src/routes/cmsRoute.ts +148 -39
- package/src/routes/pageUrl.test.ts +6 -6
- package/src/routes/pageUrl.ts +5 -4
- package/src/routes/withSiteGlobals.test.ts +37 -0
- package/src/routes/withSiteGlobals.ts +45 -24
- package/src/sdk/backgroundTasks.test.ts +68 -0
- package/src/sdk/backgroundTasks.ts +38 -0
- package/src/sdk/cdnSegment.test.ts +67 -0
- package/src/sdk/cdnSegment.ts +71 -0
- package/src/sdk/deferredSectionLoader.test.ts +1 -1
- package/src/sdk/draft.test.ts +341 -0
- package/src/sdk/draft.ts +277 -0
- package/src/sdk/draftShared.ts +16 -0
- package/src/sdk/kvHydration.test.ts +156 -0
- package/src/sdk/kvHydration.ts +82 -12
- package/src/sdk/kvRedirects.test.ts +132 -0
- package/src/sdk/kvRedirects.ts +125 -0
- package/src/sdk/router.ts +70 -61
- package/src/sdk/serverFnFetch.ts +59 -0
- package/src/sdk/speculationRules.test.ts +82 -0
- package/src/sdk/speculationRules.ts +136 -0
- package/src/sdk/startEntry.ts +31 -0
- package/src/sdk/workerEntry.cacheStorage.test.ts +147 -0
- package/src/sdk/workerEntry.test.ts +554 -16
- package/src/sdk/workerEntry.trackingCache.test.ts +328 -0
- package/src/sdk/workerEntry.ts +915 -68
- package/src/v8/__conformance__/delivery.test.ts +100 -0
- package/src/v8/binding.ts +70 -0
- package/src/v8/kvLoader.bundle.test.ts +42 -0
- package/src/v8/kvLoader.test.ts +74 -0
- package/src/v8/kvLoader.ts +43 -0
- package/src/vite/plugin.js +180 -8
- package/src/vite/plugin.test.js +126 -1
- package/src/vite/plugin.test.ts +125 -1
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clearDraftCache,
|
|
3
|
+
getRequestDraftOverride,
|
|
4
|
+
isDraftPreviewEnabled,
|
|
5
|
+
setDecoSiteHost,
|
|
6
|
+
setDraftPreviewHosts,
|
|
7
|
+
} from "@decocms/blocks/cms";
|
|
8
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
applyDraftCookieAndHeaders,
|
|
13
|
+
bindRequestDraft,
|
|
14
|
+
type DraftDecision,
|
|
15
|
+
installDecoSiteHostFromEnv,
|
|
16
|
+
installPreviewHostsFromBlocks,
|
|
17
|
+
registerDraftOverride,
|
|
18
|
+
requestCarriesDraft,
|
|
19
|
+
} from "./draft";
|
|
20
|
+
import { DRAFT_POINTER_BAG_KEY } from "./draftShared";
|
|
21
|
+
|
|
22
|
+
const HOST = "preview.example";
|
|
23
|
+
const DRAFT_BLOCKS = { "pages-home": { title: "draft" } };
|
|
24
|
+
|
|
25
|
+
function req(
|
|
26
|
+
opts: { url?: string; host?: string; cookie?: string } = {},
|
|
27
|
+
): Request {
|
|
28
|
+
const headers = new Headers();
|
|
29
|
+
headers.set("host", opts.host ?? HOST);
|
|
30
|
+
if (opts.cookie) headers.set("cookie", opts.cookie);
|
|
31
|
+
return new Request(opts.url ?? `https://${opts.host ?? HOST}/some/page`, {
|
|
32
|
+
headers,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A fetch stub serving the sandbox decofile; throws if `fail`. */
|
|
37
|
+
function sandboxFetch(fail = false): typeof fetch {
|
|
38
|
+
return (async () => {
|
|
39
|
+
if (fail) throw new Error("unreachable");
|
|
40
|
+
return new Response(JSON.stringify(DRAFT_BLOCKS), {
|
|
41
|
+
status: 200,
|
|
42
|
+
headers: { "content-type": "application/json" },
|
|
43
|
+
});
|
|
44
|
+
}) as unknown as typeof fetch;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
clearDraftCache();
|
|
49
|
+
// Allowlist is globalThis-backed (the site block's previewHosts), so no env
|
|
50
|
+
// needed — this makes the feature "enabled" for the tests below.
|
|
51
|
+
setDraftPreviewHosts([HOST, "localhost:3100"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
setDraftPreviewHosts([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("requestCarriesDraft", () => {
|
|
59
|
+
it("is inert when no allowlist is configured", () => {
|
|
60
|
+
setDraftPreviewHosts([]);
|
|
61
|
+
const url = new URL(
|
|
62
|
+
`https://${HOST}/p?__draft=abc.preview-studio.decocms.com@v1`,
|
|
63
|
+
);
|
|
64
|
+
expect(requestCarriesDraft(req({ url: url.toString() }), url)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("detects the ?__draft= entry param", () => {
|
|
68
|
+
const url = new URL(
|
|
69
|
+
`https://${HOST}/p?__draft=abc.preview-studio.decocms.com@v1`,
|
|
70
|
+
);
|
|
71
|
+
expect(requestCarriesDraft(req({ url: url.toString() }), url)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("detects the navigation cookie (SPA nav after entry)", () => {
|
|
75
|
+
const url = new URL(`https://${HOST}/p`);
|
|
76
|
+
expect(
|
|
77
|
+
requestCarriesDraft(
|
|
78
|
+
req({ url: url.toString(), cookie: "__deco_draft=abc.x@v1" }),
|
|
79
|
+
url,
|
|
80
|
+
),
|
|
81
|
+
).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("is false on ?__draft=off (leaving preview)", () => {
|
|
85
|
+
const url = new URL(`https://${HOST}/p?__draft=off`);
|
|
86
|
+
expect(
|
|
87
|
+
requestCarriesDraft(
|
|
88
|
+
req({ url: url.toString(), cookie: "__deco_draft=abc.x@v1" }),
|
|
89
|
+
url,
|
|
90
|
+
),
|
|
91
|
+
).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("is structurally inert on a non-preview host, param or not", () => {
|
|
95
|
+
const url = new URL("https://prod.example/p?__draft=abc.x@v1");
|
|
96
|
+
expect(
|
|
97
|
+
requestCarriesDraft(
|
|
98
|
+
req({ url: url.toString(), host: "prod.example" }),
|
|
99
|
+
url,
|
|
100
|
+
),
|
|
101
|
+
).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("bindRequestDraft", () => {
|
|
106
|
+
it("binds the resolved draft to the request bag on entry", async () => {
|
|
107
|
+
registerDraftOverride();
|
|
108
|
+
const pointer = "studio.decocms.com/api/o/decofile/m/main?token=t@v1";
|
|
109
|
+
const request = req({ url: `https://${HOST}/p?__draft=${pointer}` });
|
|
110
|
+
|
|
111
|
+
const decision = await RequestContext.run(request, async () => {
|
|
112
|
+
const d = await bindRequestDraft(request, sandboxFetch());
|
|
113
|
+
// loadBlocks() reads this back via the DI getter registered above.
|
|
114
|
+
expect(getRequestDraftOverride()).toEqual(DRAFT_BLOCKS);
|
|
115
|
+
return d;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(decision).toEqual({
|
|
119
|
+
previewing: true,
|
|
120
|
+
setCookie: pointer,
|
|
121
|
+
clearCookie: false,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("carries the draft across navigation via the cookie (no re-set)", async () => {
|
|
126
|
+
registerDraftOverride();
|
|
127
|
+
const pointer = "abc.preview-studio.decocms.com@v1";
|
|
128
|
+
const request = req({
|
|
129
|
+
url: `https://${HOST}/other`,
|
|
130
|
+
cookie: `__deco_draft=${pointer}`,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const decision = await RequestContext.run(request, () =>
|
|
134
|
+
bindRequestDraft(request, sandboxFetch()),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// previewing, but setCookie is null — the cookie is already present.
|
|
138
|
+
expect(decision).toEqual({
|
|
139
|
+
previewing: true,
|
|
140
|
+
setCookie: null,
|
|
141
|
+
clearCookie: false,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("decodes a URL-encoded cookie so the badge pointer round-trips (no double-encode)", async () => {
|
|
146
|
+
// The cookie is written with encodeURIComponent (`:`→%3A, `@`→%40); the
|
|
147
|
+
// pointer stashed for the badge must be the DECODED value, or the badge's
|
|
148
|
+
// share link would re-encode an already-encoded string.
|
|
149
|
+
registerDraftOverride();
|
|
150
|
+
const decoded = "abc.localhost:60534/api/o/decofile/m/main?token=t@v7";
|
|
151
|
+
const request = req({
|
|
152
|
+
url: `https://${HOST}/other`,
|
|
153
|
+
cookie: `__deco_draft=${encodeURIComponent(decoded)}`,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const stored = await RequestContext.run(request, async () => {
|
|
157
|
+
await bindRequestDraft(request, sandboxFetch());
|
|
158
|
+
return RequestContext.getBag<string>(DRAFT_POINTER_BAG_KEY);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(stored).toBe(decoded);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("stays previewing (uncacheable) even when the draft fails to resolve", async () => {
|
|
165
|
+
const request = req({
|
|
166
|
+
url: `https://${HOST}/p?__draft=abc.preview-studio.decocms.com@v1`,
|
|
167
|
+
});
|
|
168
|
+
const decision = await RequestContext.run(request, () =>
|
|
169
|
+
bindRequestDraft(request, sandboxFetch(true)),
|
|
170
|
+
);
|
|
171
|
+
expect(decision.previewing).toBe(true);
|
|
172
|
+
expect(decision.setCookie).toBe("abc.preview-studio.decocms.com@v1");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("clears the cookie on ?__draft=off", async () => {
|
|
176
|
+
const request = req({
|
|
177
|
+
url: `https://${HOST}/p?__draft=off`,
|
|
178
|
+
cookie: "__deco_draft=x@v1",
|
|
179
|
+
});
|
|
180
|
+
const decision = await RequestContext.run(request, () =>
|
|
181
|
+
bindRequestDraft(request),
|
|
182
|
+
);
|
|
183
|
+
expect(decision).toEqual({
|
|
184
|
+
previewing: false,
|
|
185
|
+
setCookie: null,
|
|
186
|
+
clearCookie: true,
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("is inert on a non-preview host", async () => {
|
|
191
|
+
const request = req({
|
|
192
|
+
url: "https://prod.example/p?__draft=abc.x@v1",
|
|
193
|
+
host: "prod.example",
|
|
194
|
+
});
|
|
195
|
+
const decision = await RequestContext.run(request, () =>
|
|
196
|
+
bindRequestDraft(request),
|
|
197
|
+
);
|
|
198
|
+
expect(decision).toEqual({
|
|
199
|
+
previewing: false,
|
|
200
|
+
setCookie: null,
|
|
201
|
+
clearCookie: false,
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("applyDraftCookieAndHeaders", () => {
|
|
207
|
+
const entry: DraftDecision = {
|
|
208
|
+
previewing: true,
|
|
209
|
+
setCookie: "abc@v1",
|
|
210
|
+
clearCookie: false,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
it("sets a cross-site-iframe-safe cookie and the anti-leak headers on entry", () => {
|
|
214
|
+
const res = new Response("ok");
|
|
215
|
+
applyDraftCookieAndHeaders(res, entry);
|
|
216
|
+
|
|
217
|
+
const cookie = res.headers.get("set-cookie") ?? "";
|
|
218
|
+
expect(cookie).toContain("__deco_draft=abc%40v1"); // pointer is URL-encoded
|
|
219
|
+
expect(cookie).toContain("SameSite=None");
|
|
220
|
+
expect(cookie).toContain("Secure");
|
|
221
|
+
expect(cookie).toContain("Partitioned");
|
|
222
|
+
expect(cookie).toContain("HttpOnly");
|
|
223
|
+
|
|
224
|
+
expect(res.headers.get("cache-control")).toBe("no-store, private");
|
|
225
|
+
expect(res.headers.get("vary")).toBe("Cookie");
|
|
226
|
+
expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("expires the cookie when leaving preview", () => {
|
|
230
|
+
const res = new Response("ok");
|
|
231
|
+
applyDraftCookieAndHeaders(res, {
|
|
232
|
+
previewing: false,
|
|
233
|
+
setCookie: null,
|
|
234
|
+
clearCookie: true,
|
|
235
|
+
});
|
|
236
|
+
expect(res.headers.get("set-cookie") ?? "").toContain("Max-Age=0");
|
|
237
|
+
// Not previewing → no cache/index headers.
|
|
238
|
+
expect(res.headers.get("cache-control")).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("merges Cookie into an existing Vary instead of clobbering it", () => {
|
|
242
|
+
const res = new Response("ok", { headers: { Vary: "Accept-Encoding" } });
|
|
243
|
+
applyDraftCookieAndHeaders(res, {
|
|
244
|
+
previewing: true,
|
|
245
|
+
setCookie: null,
|
|
246
|
+
clearCookie: false,
|
|
247
|
+
});
|
|
248
|
+
expect(res.headers.get("vary")).toBe("Accept-Encoding, Cookie");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("leaves a Vary that already lists Cookie untouched (no token dropped)", () => {
|
|
252
|
+
const res = new Response("ok", {
|
|
253
|
+
headers: { Vary: "Accept-Encoding, Cookie" },
|
|
254
|
+
});
|
|
255
|
+
applyDraftCookieAndHeaders(res, {
|
|
256
|
+
previewing: true,
|
|
257
|
+
setCookie: null,
|
|
258
|
+
clearCookie: false,
|
|
259
|
+
});
|
|
260
|
+
expect(res.headers.get("vary")).toBe("Accept-Encoding, Cookie");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("does nothing for an ordinary (non-draft) response", () => {
|
|
264
|
+
const res = new Response("ok");
|
|
265
|
+
applyDraftCookieAndHeaders(res, {
|
|
266
|
+
previewing: false,
|
|
267
|
+
setCookie: null,
|
|
268
|
+
clearCookie: false,
|
|
269
|
+
});
|
|
270
|
+
expect(res.headers.get("set-cookie")).toBeNull();
|
|
271
|
+
expect(res.headers.get("cache-control")).toBeNull();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("installPreviewHostsFromBlocks", () => {
|
|
276
|
+
it("installs previewHosts from the site block (either casing)", async () => {
|
|
277
|
+
setDraftPreviewHosts([]);
|
|
278
|
+
installPreviewHostsFromBlocks({
|
|
279
|
+
Site: { previewHosts: ["acme.vtex.app"] },
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Verified through the wire: an allowed host now previews.
|
|
283
|
+
const request = req({
|
|
284
|
+
url: "https://acme.vtex.app/p?__draft=abc.preview-studio.decocms.com@v1",
|
|
285
|
+
host: "acme.vtex.app",
|
|
286
|
+
});
|
|
287
|
+
const url = new URL(request.url);
|
|
288
|
+
expect(requestCarriesDraft(request, url)).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("ignores blocks without previewHosts", () => {
|
|
292
|
+
setDraftPreviewHosts([]);
|
|
293
|
+
installPreviewHostsFromBlocks({ site: {} });
|
|
294
|
+
const url = new URL("https://acme.vtex.app/p?__draft=x@v1");
|
|
295
|
+
expect(
|
|
296
|
+
requestCarriesDraft(
|
|
297
|
+
req({ url: url.toString(), host: "acme.vtex.app" }),
|
|
298
|
+
url,
|
|
299
|
+
),
|
|
300
|
+
).toBe(false);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("installDecoSiteHostFromEnv", () => {
|
|
305
|
+
afterEach(() => {
|
|
306
|
+
setDecoSiteHost(null);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("arms the deco-hosted domains from the DECO_SITE_NAME binding", () => {
|
|
310
|
+
setDraftPreviewHosts([]);
|
|
311
|
+
installDecoSiteHostFromEnv({ DECO_SITE_NAME: "acme-tanstack" });
|
|
312
|
+
|
|
313
|
+
// Verified through the wire: both deco-operated hosts now preview, with no
|
|
314
|
+
// site-block/env config at all.
|
|
315
|
+
for (const host of [
|
|
316
|
+
"acme-tanstack.deco.site",
|
|
317
|
+
"acme-tanstack.deco-cx.workers.dev",
|
|
318
|
+
]) {
|
|
319
|
+
const url = new URL(
|
|
320
|
+
`https://${host}/p?__draft=abc.preview-studio.decocms.com@v1`,
|
|
321
|
+
);
|
|
322
|
+
expect(requestCarriesDraft(req({ url: url.toString(), host }), url)).toBe(true);
|
|
323
|
+
}
|
|
324
|
+
// A custom production domain is never inferred.
|
|
325
|
+
const url = new URL("https://www.acme.com.br/p?__draft=x@v1");
|
|
326
|
+
expect(
|
|
327
|
+
requestCarriesDraft(
|
|
328
|
+
req({ url: url.toString(), host: "www.acme.com.br" }),
|
|
329
|
+
url,
|
|
330
|
+
),
|
|
331
|
+
).toBe(false);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("an unset or non-string binding registers nothing", () => {
|
|
335
|
+
setDraftPreviewHosts([]);
|
|
336
|
+
installDecoSiteHostFromEnv({});
|
|
337
|
+
expect(isDraftPreviewEnabled({})).toBe(false);
|
|
338
|
+
installDecoSiteHostFromEnv({ DECO_SITE_NAME: 42 });
|
|
339
|
+
expect(isDraftPreviewEnabled({})).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
});
|
package/src/sdk/draft.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
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
|
+
setDecoSiteHost,
|
|
41
|
+
setDraftOverrideGetter,
|
|
42
|
+
setDraftPreviewHosts,
|
|
43
|
+
} from "@decocms/blocks/cms";
|
|
44
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
45
|
+
import { DRAFT_POINTER_BAG_KEY } from "./draftShared";
|
|
46
|
+
|
|
47
|
+
/** RequestContext bag key the resolved draft decofile is stashed under. */
|
|
48
|
+
const DRAFT_BAG_KEY = "deco:draftBlocks";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Cookie lifetime. Short on purpose: a preview session is minutes, and a stale
|
|
52
|
+
* pointer would keep pinning an old content version. Matches the Next binding.
|
|
53
|
+
*/
|
|
54
|
+
const DRAFT_COOKIE_MAX_AGE = 60 * 30;
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Runtime binding (dependency injection into @decocms/blocks)
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
let registered = false;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Register the request-scoped draft getter with the runtime, once.
|
|
64
|
+
*
|
|
65
|
+
* `loadBlocks()` consults `getRequestDraftOverride()` as a fallback after its
|
|
66
|
+
* own `withBlocksOverride` scope; this points that fallback at the
|
|
67
|
+
* RequestContext bag {@link bindRequestDraft} fills. Idempotent and safe to
|
|
68
|
+
* call at setup: outside a request scope `getBag` returns undefined, so
|
|
69
|
+
* `loadBlocks()` behaves exactly as before.
|
|
70
|
+
*/
|
|
71
|
+
export function registerDraftOverride(): void {
|
|
72
|
+
if (registered) return;
|
|
73
|
+
registered = true;
|
|
74
|
+
setDraftOverrideGetter(
|
|
75
|
+
() => RequestContext.getBag<Record<string, unknown>>(DRAFT_BAG_KEY) ?? null,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Install the site block's `previewHosts` as the draft-preview allowlist.
|
|
81
|
+
*
|
|
82
|
+
* Reads the BASE blocks (the `setBlocks()` argument / `loadBlocks()` at setup
|
|
83
|
+
* time), never the request-time merged blocks: an allowlist readable through
|
|
84
|
+
* the draft override could be rewritten by the very draft it gates.
|
|
85
|
+
* `DECO_ALLOWED_PREVIEW_HOSTS` remains an operational override that replaces
|
|
86
|
+
* this list when set. Handles both decofile shapes (`site` / `Site`), same as
|
|
87
|
+
* `withSiteGlobals`.
|
|
88
|
+
*/
|
|
89
|
+
export function installPreviewHostsFromBlocks(blocks: Record<string, unknown> | undefined): void {
|
|
90
|
+
const site = (blocks?.site ?? blocks?.Site) as { previewHosts?: unknown } | undefined;
|
|
91
|
+
if (Array.isArray(site?.previewHosts)) setDraftPreviewHosts(site.previewHosts);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Register the deco-operated preview hosts inferred from the site name —
|
|
96
|
+
* `<site>.deco.site` and `<site>.deco-cx.workers.dev` (the workers.dev deploy
|
|
97
|
+
* URL, e.g. `acme-tanstack.deco-cx.workers.dev`) — so a signed
|
|
98
|
+
* `?__draft=` grant previews on deco-hosted infra with zero per-site config.
|
|
99
|
+
*
|
|
100
|
+
* Fed from the Workers env binding `DECO_SITE_NAME`: deploy-time configuration
|
|
101
|
+
* set by deco's hosting, trusted the same way `DECO_ALLOWED_PREVIEW_HOSTS` is —
|
|
102
|
+
* never derived from the request. Merged ON TOP of the site block/env list
|
|
103
|
+
* (see `setDecoSiteHost` in `@decocms/blocks`); an unset binding registers
|
|
104
|
+
* nothing, and `DECO_ALLOWED_PREVIEW_HOSTS=none` kills the inferred hosts too.
|
|
105
|
+
*
|
|
106
|
+
* This inference is deliberately tanstack-only (like Fast Deploy): the
|
|
107
|
+
* deco-operated domains only ever serve Workers deployments, and in the Next
|
|
108
|
+
* binding flipping `isDraftPreviewEnabled()` on costs every page its
|
|
109
|
+
* static/ISR rendering — Next sites keep the explicit opt-in.
|
|
110
|
+
*/
|
|
111
|
+
export function installDecoSiteHostFromEnv(env: Record<string, unknown>): void {
|
|
112
|
+
const site = env.DECO_SITE_NAME;
|
|
113
|
+
setDecoSiteHost(typeof site === "string" ? site : undefined);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Per-request resolution + response decoration
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read one cookie value out of a raw `Cookie:` header.
|
|
122
|
+
*
|
|
123
|
+
* Decodes the value — the cookie is written with `encodeURIComponent` (see
|
|
124
|
+
* `draftCookie`), and `@decocms/blocks`'s own cookie reader decodes too, so a
|
|
125
|
+
* cookie-carried pointer must round-trip identically or the badge's share link
|
|
126
|
+
* would double-encode it. Decode defensively: a malformed `%` sequence
|
|
127
|
+
* (a crafted cookie) falls back to the raw value rather than throwing and
|
|
128
|
+
* 500-ing the request.
|
|
129
|
+
*/
|
|
130
|
+
function readCookie(cookieHeader: string | null, name: string): string | null {
|
|
131
|
+
if (!cookieHeader) return null;
|
|
132
|
+
for (const part of cookieHeader.split(";")) {
|
|
133
|
+
const eq = part.indexOf("=");
|
|
134
|
+
if (eq === -1) continue;
|
|
135
|
+
if (part.slice(0, eq).trim() === name) {
|
|
136
|
+
const raw = part.slice(eq + 1).trim();
|
|
137
|
+
try {
|
|
138
|
+
return decodeURIComponent(raw);
|
|
139
|
+
} catch {
|
|
140
|
+
return raw;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Whether a request is in draft mode — carries a live `?__draft=` param or the
|
|
149
|
+
* navigation cookie — on a host allowed to preview.
|
|
150
|
+
*
|
|
151
|
+
* A pure, synchronous, network-free check (no fetch, no resolve): the worker
|
|
152
|
+
* uses it to keep drafted requests out of the shared edge cache *before* doing
|
|
153
|
+
* any work. Gated on `isDraftPreviewEnabled()` first, so a build without the
|
|
154
|
+
* allowlist configured is completely unaffected — a stray `?__draft=` on a
|
|
155
|
+
* production domain neither renders a draft nor even bypasses the cache.
|
|
156
|
+
*/
|
|
157
|
+
export function requestCarriesDraft(request: Request, url: URL): boolean {
|
|
158
|
+
if (!isDraftPreviewEnabled()) return false;
|
|
159
|
+
const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
|
160
|
+
if (!isDraftHostAllowed(host)) return false;
|
|
161
|
+
const param = url.searchParams.get(DRAFT_QUERY_PARAM);
|
|
162
|
+
if (param === "off") return false;
|
|
163
|
+
if (param) return true;
|
|
164
|
+
return readCookie(request.headers.get("cookie"), DRAFT_COOKIE_NAME) !== null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The cookie/header actions a drafted request implies for its response. */
|
|
168
|
+
export interface DraftDecision {
|
|
169
|
+
/**
|
|
170
|
+
* The request is previewing (a pointer is present on an allowed host), so its
|
|
171
|
+
* response must never be cached under the shared (cookie-only) URL — even if
|
|
172
|
+
* the draft failed to resolve and the page fell back to published content.
|
|
173
|
+
*/
|
|
174
|
+
previewing: boolean;
|
|
175
|
+
/** Persist this pointer in the navigation cookie (set only on `?__draft=` entry). */
|
|
176
|
+
setCookie: string | null;
|
|
177
|
+
/** Clear the navigation cookie (`?__draft=off`). */
|
|
178
|
+
clearCookie: boolean;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const INERT: DraftDecision = { previewing: false, setCookie: null, clearCookie: false };
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Resolve this request's draft, if any, and bind it to the RequestContext so
|
|
185
|
+
* every `loadBlocks()` in the render sees the merged decofile.
|
|
186
|
+
*
|
|
187
|
+
* Call inside `RequestContext.run`, before the page render. Returns the
|
|
188
|
+
* cookie/header decision for the worker to apply to the final response. A
|
|
189
|
+
* missing allowlist, disallowed host, or `?__draft=off` all short-circuit
|
|
190
|
+
* without touching the network; a resolve failure degrades to published but
|
|
191
|
+
* still reports `previewing: true` so the response stays uncacheable.
|
|
192
|
+
*/
|
|
193
|
+
export async function bindRequestDraft(
|
|
194
|
+
request: Request,
|
|
195
|
+
fetchImpl?: typeof fetch,
|
|
196
|
+
): Promise<DraftDecision> {
|
|
197
|
+
if (!isDraftPreviewEnabled()) return INERT;
|
|
198
|
+
|
|
199
|
+
const url = new URL(request.url);
|
|
200
|
+
const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
|
201
|
+
// Authoritative host gate — unlike Next, there is no separate middleware
|
|
202
|
+
// module graph, so this single check bounds the whole feature.
|
|
203
|
+
if (!isDraftHostAllowed(host)) return INERT;
|
|
204
|
+
|
|
205
|
+
const param = url.searchParams.get(DRAFT_QUERY_PARAM);
|
|
206
|
+
if (param === "off") return { previewing: false, setCookie: null, clearCookie: true };
|
|
207
|
+
|
|
208
|
+
const cookie = readCookie(request.headers.get("cookie"), DRAFT_COOKIE_NAME);
|
|
209
|
+
const pointer = param ?? cookie ?? null;
|
|
210
|
+
if (!pointer) return INERT;
|
|
211
|
+
|
|
212
|
+
const blocks = await resolveDraftForRequest(request, { fetchImpl });
|
|
213
|
+
if (blocks) {
|
|
214
|
+
RequestContext.setBag(DRAFT_BAG_KEY, blocks);
|
|
215
|
+
// Expose the pointer to the SSR React tree so the preview badge can render
|
|
216
|
+
// and build its share/exit links. Only when the draft actually bound —
|
|
217
|
+
// the badge means "you're looking at draft content", matching Next.
|
|
218
|
+
RequestContext.setBag(DRAFT_POINTER_BAG_KEY, pointer);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Persist only on entry (`?__draft=`); in-preview navigation rides the cookie
|
|
222
|
+
// already present.
|
|
223
|
+
return { previewing: true, setCookie: param ?? null, clearCookie: false };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Build the `Set-Cookie` value for the draft navigation cookie. */
|
|
227
|
+
function draftCookie(value: string, maxAge: number): string {
|
|
228
|
+
// SameSite=None; Secure is mandatory for the cross-site preview iframe, and
|
|
229
|
+
// Partitioned (CHIPS) keeps it working as browsers wind down unpartitioned
|
|
230
|
+
// third-party cookies. HttpOnly: the pointer is read server-side only.
|
|
231
|
+
return `${DRAFT_COOKIE_NAME}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=None; Partitioned`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Set a header defensively — some replayed responses seal their headers. */
|
|
235
|
+
function trySet(response: Response, key: string, value: string): void {
|
|
236
|
+
try {
|
|
237
|
+
response.headers.set(key, value);
|
|
238
|
+
} catch {
|
|
239
|
+
/* sealed headers (cached replay) — best-effort */
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Apply the cookie lifecycle and the cache/indexing headers a draft response
|
|
245
|
+
* requires.
|
|
246
|
+
*
|
|
247
|
+
* The caching headers are the difference between a preview and a leak: with the
|
|
248
|
+
* pointer in a cookie, a drafted response and a published one share an
|
|
249
|
+
* identical URL, so `no-store` is what stops a CDN keyed on URL alone from
|
|
250
|
+
* serving unpublished content to a real visitor. `Vary: Cookie` keeps any
|
|
251
|
+
* intermediary that does respect it from mixing the two; `X-Robots-Tag` keeps a
|
|
252
|
+
* leaked draft out of search results.
|
|
253
|
+
*/
|
|
254
|
+
export function applyDraftCookieAndHeaders(response: Response, decision: DraftDecision): void {
|
|
255
|
+
if (decision.clearCookie) {
|
|
256
|
+
response.headers.append("set-cookie", draftCookie("", 0));
|
|
257
|
+
} else if (decision.setCookie) {
|
|
258
|
+
response.headers.append(
|
|
259
|
+
"set-cookie",
|
|
260
|
+
draftCookie(encodeURIComponent(decision.setCookie), DRAFT_COOKIE_MAX_AGE),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (decision.previewing) {
|
|
265
|
+
trySet(response, "cache-control", "no-store, private");
|
|
266
|
+
// Add Cookie to Vary without dropping tokens already there (e.g.
|
|
267
|
+
// Accept-Encoding). Moot under no-store, but correct for any intermediary
|
|
268
|
+
// that honours Vary anyway.
|
|
269
|
+
const existingVary = response.headers.get("vary");
|
|
270
|
+
if (!existingVary) {
|
|
271
|
+
trySet(response, "vary", "Cookie");
|
|
272
|
+
} else if (!/\bcookie\b/i.test(existingVary)) {
|
|
273
|
+
trySet(response, "vary", `${existingVary}, Cookie`);
|
|
274
|
+
}
|
|
275
|
+
trySet(response, "x-robots-tag", "noindex, nofollow");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -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__";
|