@decocms/blocks 7.28.0-beta.2 → 7.28.0-beta.4
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 +1 -1
- package/src/cms/applySectionConventions.ts +8 -12
- package/src/cms/blockSource.test.ts +1 -1
- package/src/cms/client.browserBundle.test.ts +2 -2
- package/src/cms/client.ts +8 -2
- package/src/cms/draftSource.test.ts +138 -144
- package/src/cms/draftSource.ts +134 -112
- package/src/cms/index.ts +3 -2
- package/src/cms/loader.test.ts +7 -9
- package/src/cms/registry.test.ts +16 -24
- package/src/cms/registry.ts +5 -15
- package/src/cms/resolve.test.ts +20 -22
- package/src/cms/resolve.ts +15 -18
- package/src/cms/schema.ts +1 -4
- package/src/cms/sectionMixins.test.ts +13 -9
- package/src/cms/sectionMixins.ts +1 -3
package/package.json
CHANGED
|
@@ -6,23 +6,20 @@
|
|
|
6
6
|
* registerSectionsSync, setAsyncRenderingConfig, registerCacheableSections,
|
|
7
7
|
* registerLayoutSections, registerSeoSections, and registerSection.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
10
|
-
registerSection,
|
|
11
|
-
registerSectionsSync,
|
|
12
|
-
} from "./registry";
|
|
13
|
-
import {
|
|
14
|
-
type CacheableSectionInput,
|
|
15
|
-
registerCacheableSections,
|
|
16
|
-
registerLayoutSections,
|
|
17
|
-
} from "./sectionLoaders";
|
|
9
|
+
import { registerSection, registerSectionsSync } from "./registry";
|
|
18
10
|
import {
|
|
19
11
|
type AsyncRenderingConfig,
|
|
12
|
+
getAsyncRenderingConfig,
|
|
20
13
|
registerEagerSections,
|
|
21
14
|
registerNeverDeferSections,
|
|
22
15
|
registerSeoSections,
|
|
23
16
|
setAsyncRenderingConfig,
|
|
24
|
-
getAsyncRenderingConfig,
|
|
25
17
|
} from "./resolve";
|
|
18
|
+
import {
|
|
19
|
+
type CacheableSectionInput,
|
|
20
|
+
registerCacheableSections,
|
|
21
|
+
registerLayoutSections,
|
|
22
|
+
} from "./sectionLoaders";
|
|
26
23
|
|
|
27
24
|
export interface SectionMetaEntry {
|
|
28
25
|
eager?: boolean;
|
|
@@ -96,8 +93,7 @@ export function applySectionConventions(input: ApplySectionConventionsInput): vo
|
|
|
96
93
|
// it, asyncConfig stays null, `useAsync` is false in resolveDecoPage, and
|
|
97
94
|
// even editor-marked ⚡ sections render eagerly. (The default foldThreshold is
|
|
98
95
|
// Infinity, so position-based deferral stays off unless a site opts in.)
|
|
99
|
-
const existing: Partial<AsyncRenderingConfig> =
|
|
100
|
-
getAsyncRenderingConfig() ?? {};
|
|
96
|
+
const existing: Partial<AsyncRenderingConfig> = getAsyncRenderingConfig() ?? {};
|
|
101
97
|
setAsyncRenderingConfig({
|
|
102
98
|
...existing,
|
|
103
99
|
alwaysEager: [...(existing.alwaysEager ?? []), ...eagerSections],
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { djb2Hex } from "../sdk/djb2";
|
|
2
3
|
import {
|
|
3
4
|
BundledBlockSource,
|
|
4
5
|
computeRevision,
|
|
@@ -8,7 +9,6 @@ import {
|
|
|
8
9
|
revisionKey,
|
|
9
10
|
snapshotKey,
|
|
10
11
|
} from "./blockSource";
|
|
11
|
-
import { djb2Hex } from "../sdk/djb2";
|
|
12
12
|
|
|
13
13
|
describe("computeRevision", () => {
|
|
14
14
|
it("matches loader.ts computeRevision (djb2Hex of JSON.stringify)", () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
1
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
2
|
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
5
|
|
|
6
6
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
7
|
// esbuild's JS API relies on `new TextEncoder().encode("") instanceof
|
package/src/cms/client.ts
CHANGED
|
@@ -49,8 +49,6 @@ export {
|
|
|
49
49
|
registerSectionsSync,
|
|
50
50
|
setResolvedComponent,
|
|
51
51
|
} from "./registry";
|
|
52
|
-
export type { SectionLoaderFn } from "./sectionLoaders";
|
|
53
|
-
export { compose, withDevice, withMobile, withSearchParam, withSectionLoader } from "./sectionMixins";
|
|
54
52
|
export type {
|
|
55
53
|
ActionConfig,
|
|
56
54
|
AppSchemas,
|
|
@@ -72,3 +70,11 @@ export {
|
|
|
72
70
|
registerMatcherSchema,
|
|
73
71
|
registerMatcherSchemas,
|
|
74
72
|
} from "./schema";
|
|
73
|
+
export type { SectionLoaderFn } from "./sectionLoaders";
|
|
74
|
+
export {
|
|
75
|
+
compose,
|
|
76
|
+
withDevice,
|
|
77
|
+
withMobile,
|
|
78
|
+
withSearchParam,
|
|
79
|
+
withSectionLoader,
|
|
80
|
+
} from "./sectionMixins";
|
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
buildDraftOrigin,
|
|
5
4
|
clearDraftCache,
|
|
6
|
-
|
|
5
|
+
DEFAULT_PREVIEW_API_DOMAINS,
|
|
7
6
|
isDraftHostAllowed,
|
|
8
7
|
isDraftPreviewEnabled,
|
|
9
8
|
parseDraftPointer,
|
|
9
|
+
previewApiOriginForHost,
|
|
10
10
|
resolveDraftDecofile,
|
|
11
|
+
setDraftPreviewHosts,
|
|
11
12
|
} from "./draftSource";
|
|
12
13
|
|
|
13
|
-
const ENV_ON = {
|
|
14
|
-
DECO_DRAFT_PREVIEW_HOST: "preview.example",
|
|
15
|
-
DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com",
|
|
16
|
-
};
|
|
14
|
+
const ENV_ON = { DECO_ALLOWED_PREVIEW_HOSTS: "preview.example" };
|
|
17
15
|
|
|
18
16
|
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
|
19
17
|
return new Response(JSON.stringify(body), {
|
|
@@ -28,106 +26,99 @@ beforeEach(() => {
|
|
|
28
26
|
});
|
|
29
27
|
|
|
30
28
|
describe("parseDraftPointer", () => {
|
|
31
|
-
it("parses
|
|
32
|
-
expect(parseDraftPointer("
|
|
33
|
-
|
|
34
|
-
version: "
|
|
29
|
+
it("parses authority@version, lowercasing the authority", () => {
|
|
30
|
+
expect(parseDraftPointer("ABC.Preview-Studio.decocms.com@FF00")).toEqual({
|
|
31
|
+
host: "abc.preview-studio.decocms.com",
|
|
32
|
+
version: "FF00",
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("keeps an explicit port on the authority", () => {
|
|
37
|
+
expect(parseDraftPointer("abc.localhost:60534@v1")).toEqual({
|
|
38
|
+
host: "abc.localhost:60534",
|
|
39
|
+
version: "v1",
|
|
35
40
|
});
|
|
36
41
|
});
|
|
37
42
|
|
|
38
43
|
it("rejects more than one @", () => {
|
|
39
44
|
// A naive split("@") accepts this and silently uses the first two
|
|
40
45
|
// segments — the exact hole found while spiking the fetch path.
|
|
41
|
-
expect(parseDraftPointer("a@b@c")).toBeNull();
|
|
46
|
+
expect(parseDraftPointer("a.example@b@c")).toBeNull();
|
|
42
47
|
});
|
|
43
48
|
|
|
44
|
-
it("rejects
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
expect(parseDraftPointer("
|
|
48
|
-
expect(parseDraftPointer(".
|
|
49
|
+
it("rejects anything that could escape the authority", () => {
|
|
50
|
+
// No scheme, no path, no userinfo — the token carries an authority only,
|
|
51
|
+
// so `javascript:`/`file:`/full URLs fail structurally at parse time.
|
|
52
|
+
expect(parseDraftPointer("https://evil.example@v1")).toBeNull();
|
|
53
|
+
expect(parseDraftPointer("evil.example/x@v1")).toBeNull();
|
|
54
|
+
expect(parseDraftPointer("a.example:80:80@v1")).toBeNull();
|
|
55
|
+
expect(parseDraftPointer("a.example:abc@v1")).toBeNull();
|
|
56
|
+
expect(parseDraftPointer(".leading.dot@v1")).toBeNull();
|
|
57
|
+
expect(parseDraftPointer("bare-label@v1")).toBeNull();
|
|
49
58
|
});
|
|
50
59
|
|
|
51
|
-
it("
|
|
52
|
-
expect(parseDraftPointer("@
|
|
53
|
-
expect(parseDraftPointer("
|
|
54
|
-
expect(parseDraftPointer("
|
|
60
|
+
it("validates the version charset — it becomes a cache key", () => {
|
|
61
|
+
expect(parseDraftPointer("a.example@")).toBeNull();
|
|
62
|
+
expect(parseDraftPointer(`a.example@${"x".repeat(65)}`)).toBeNull();
|
|
63
|
+
expect(parseDraftPointer("a.example@v 1")).toBeNull();
|
|
55
64
|
expect(parseDraftPointer(null)).toBeNull();
|
|
56
|
-
expect(parseDraftPointer("")).toBeNull();
|
|
57
65
|
});
|
|
58
66
|
});
|
|
59
67
|
|
|
60
|
-
describe("
|
|
61
|
-
it("
|
|
62
|
-
expect(
|
|
68
|
+
describe("previewApiOriginForHost", () => {
|
|
69
|
+
it("admits authorities under the default deco domains", () => {
|
|
70
|
+
expect(previewApiOriginForHost("abc.preview-studio.decocms.com", {})).toBe(
|
|
63
71
|
"https://abc.preview-studio.decocms.com",
|
|
64
72
|
);
|
|
73
|
+
expect(previewApiOriginForHost("abc.local.studio.decocms.com", {})).toBe(
|
|
74
|
+
"https://abc.local.studio.decocms.com",
|
|
75
|
+
);
|
|
76
|
+
expect(previewApiOriginForHost("abc.localhost:60534", {})).toBe("http://abc.localhost:60534");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("rejects hosts outside the domains — the token proposes, config disposes", () => {
|
|
80
|
+
expect(previewApiOriginForHost("evil.example", {})).toBeNull();
|
|
81
|
+
// Dot-prefixed suffixes guarantee a label boundary: a lookalike domain
|
|
82
|
+
// that merely ends with the same characters cannot pass.
|
|
83
|
+
expect(previewApiOriginForHost("evil-preview-studio.decocms.com", {})).toBeNull();
|
|
84
|
+
// The domain itself (no label in front) is not a draft host.
|
|
85
|
+
expect(previewApiOriginForHost("preview-studio.decocms.com", {})).toBeNull();
|
|
65
86
|
});
|
|
66
87
|
|
|
67
|
-
it("
|
|
68
|
-
|
|
88
|
+
it("allows an explicit port only under localhost-ish domains", () => {
|
|
89
|
+
// A public-domain token must not steer the fetch at odd ports.
|
|
90
|
+
expect(previewApiOriginForHost("abc.preview-studio.decocms.com:8500", {})).toBeNull();
|
|
69
91
|
});
|
|
70
92
|
|
|
71
|
-
it("
|
|
72
|
-
|
|
93
|
+
it("honours a configured override instead of the defaults", () => {
|
|
94
|
+
const env = { DECO_PREVIEW_API_DOMAINS: ".staging.example" };
|
|
95
|
+
expect(previewApiOriginForHost("abc.staging.example", env)).toBe("https://abc.staging.example");
|
|
96
|
+
expect(previewApiOriginForHost("abc.preview-studio.decocms.com", env)).toBeNull();
|
|
73
97
|
});
|
|
74
98
|
});
|
|
75
99
|
|
|
76
|
-
describe("
|
|
77
|
-
it("is on iff an allowed host is configured —
|
|
78
|
-
// Inverted from the old two-key gate: suffixes now default to the
|
|
79
|
-
// deco-operated origins, so the single opt-in is the host allowlist.
|
|
80
|
-
// Upgrading the package with no host configured stays fully inert.
|
|
100
|
+
describe("gating", () => {
|
|
101
|
+
it("is on iff an allowed host is configured — API domains have defaults", () => {
|
|
81
102
|
expect(isDraftPreviewEnabled(ENV_ON)).toBe(true);
|
|
82
|
-
expect(isDraftPreviewEnabled({ DECO_DRAFT_PREVIEW_HOST: "a.example" })).toBe(true);
|
|
83
|
-
expect(
|
|
84
|
-
isDraftPreviewEnabled({
|
|
85
|
-
DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com",
|
|
86
|
-
}),
|
|
87
|
-
).toBe(false);
|
|
88
103
|
expect(isDraftPreviewEnabled({})).toBe(false);
|
|
89
104
|
});
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
describe("isDraftHostAllowed", () => {
|
|
93
|
-
const env = { DECO_DRAFT_PREVIEW_HOST: "fila.vtex.app, localhost:3100" };
|
|
94
105
|
|
|
95
|
-
it("matches
|
|
96
|
-
|
|
106
|
+
it("matches request hosts verbatim, port included, case-insensitively", () => {
|
|
107
|
+
const env = { DECO_ALLOWED_PREVIEW_HOSTS: "fila.vtex.app, localhost:3100" };
|
|
97
108
|
expect(isDraftHostAllowed("FILA.VTEX.APP", env)).toBe(true);
|
|
98
109
|
expect(isDraftHostAllowed("localhost:3100", env)).toBe(true);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("rejects everything else — the production domain on the same build", () => {
|
|
102
110
|
expect(isDraftHostAllowed("fila.com.br", env)).toBe(false);
|
|
103
|
-
expect(isDraftHostAllowed("localhost", env)).toBe(false);
|
|
111
|
+
expect(isDraftHostAllowed("localhost", env)).toBe(false);
|
|
104
112
|
expect(isDraftHostAllowed(null, env)).toBe(false);
|
|
105
113
|
expect(isDraftHostAllowed("fila.vtex.app", {})).toBe(false);
|
|
106
114
|
});
|
|
107
115
|
});
|
|
108
116
|
|
|
109
117
|
describe("resolveDraftDecofile", () => {
|
|
110
|
-
it("
|
|
111
|
-
const calls: string[] = [];
|
|
112
|
-
await resolveDraftDecofile({
|
|
113
|
-
pointer: "abc@v1",
|
|
114
|
-
env: { DECO_DRAFT_PREVIEW_HOST: "preview.example" },
|
|
115
|
-
fetchImpl: (async (url: string) => {
|
|
116
|
-
calls.push(String(url));
|
|
117
|
-
throw new Error("unreachable");
|
|
118
|
-
}) as unknown as typeof fetch,
|
|
119
|
-
});
|
|
120
|
-
expect(calls).toEqual(
|
|
121
|
-
DEFAULT_SANDBOX_ORIGIN_SUFFIXES.map(
|
|
122
|
-
(sfx) => `${sfx.includes("localhost") ? "http" : "https"}://abc${sfx}/_sandbox/decofile`,
|
|
123
|
-
),
|
|
124
|
-
);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("fetches the sandbox decofile and returns it", async () => {
|
|
118
|
+
it("fetches exactly the token's validated origin", async () => {
|
|
128
119
|
const calls: string[] = [];
|
|
129
120
|
const blocks = await resolveDraftDecofile({
|
|
130
|
-
pointer: "abc@v1",
|
|
121
|
+
pointer: "abc.preview-studio.decocms.com@v1",
|
|
131
122
|
env: ENV_ON,
|
|
132
123
|
fetchImpl: (async (url: string) => {
|
|
133
124
|
calls.push(String(url));
|
|
@@ -142,14 +133,27 @@ describe("resolveDraftDecofile", () => {
|
|
|
142
133
|
it("is inert without a host allowlist — no fetch at all", async () => {
|
|
143
134
|
let called = false;
|
|
144
135
|
const blocks = await resolveDraftDecofile({
|
|
145
|
-
pointer: "abc@v1",
|
|
146
|
-
env: {
|
|
136
|
+
pointer: "abc.preview-studio.decocms.com@v1",
|
|
137
|
+
env: {},
|
|
147
138
|
fetchImpl: (async () => {
|
|
148
139
|
called = true;
|
|
149
140
|
return jsonResponse({});
|
|
150
141
|
}) as unknown as typeof fetch,
|
|
151
142
|
});
|
|
143
|
+
expect(blocks).toBeNull();
|
|
144
|
+
expect(called).toBe(false);
|
|
145
|
+
});
|
|
152
146
|
|
|
147
|
+
it("refuses a parseable token whose origin no domain admits — no fetch", async () => {
|
|
148
|
+
let called = false;
|
|
149
|
+
const blocks = await resolveDraftDecofile({
|
|
150
|
+
pointer: "abc.evil.example@v1",
|
|
151
|
+
env: ENV_ON,
|
|
152
|
+
fetchImpl: (async () => {
|
|
153
|
+
called = true;
|
|
154
|
+
return jsonResponse({});
|
|
155
|
+
}) as unknown as typeof fetch,
|
|
156
|
+
});
|
|
153
157
|
expect(blocks).toBeNull();
|
|
154
158
|
expect(called).toBe(false);
|
|
155
159
|
});
|
|
@@ -160,13 +164,14 @@ describe("resolveDraftDecofile", () => {
|
|
|
160
164
|
fetches++;
|
|
161
165
|
return jsonResponse({ n: fetches });
|
|
162
166
|
}) as unknown as typeof fetch;
|
|
167
|
+
const P = "abc.preview-studio.decocms.com";
|
|
163
168
|
|
|
164
|
-
const a = await resolveDraftDecofile({ pointer:
|
|
165
|
-
const b = await resolveDraftDecofile({ pointer:
|
|
169
|
+
const a = await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
|
|
170
|
+
const b = await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
|
|
166
171
|
expect(fetches).toBe(1);
|
|
167
172
|
expect(b).toBe(a);
|
|
168
173
|
|
|
169
|
-
await resolveDraftDecofile({ pointer:
|
|
174
|
+
await resolveDraftDecofile({ pointer: `${P}@v2`, env: ENV_ON, fetchImpl });
|
|
170
175
|
expect(fetches).toBe(2);
|
|
171
176
|
});
|
|
172
177
|
|
|
@@ -176,89 +181,78 @@ describe("resolveDraftDecofile", () => {
|
|
|
176
181
|
fetches++;
|
|
177
182
|
return jsonResponse({ n: fetches });
|
|
178
183
|
}) as unknown as typeof fetch;
|
|
184
|
+
const P = "abc.preview-studio.decocms.com";
|
|
179
185
|
|
|
180
186
|
for (const v of ["v1", "v2", "v3", "v4"]) {
|
|
181
|
-
await resolveDraftDecofile({ pointer:
|
|
187
|
+
await resolveDraftDecofile({ pointer: `${P}@${v}`, env: ENV_ON, fetchImpl });
|
|
182
188
|
}
|
|
183
189
|
expect(fetches).toBe(4);
|
|
184
|
-
|
|
185
|
-
// v1
|
|
186
|
-
await resolveDraftDecofile({ pointer:
|
|
187
|
-
expect(fetches).toBe(5);
|
|
188
|
-
|
|
189
|
-
// v4 is still resident.
|
|
190
|
-
await resolveDraftDecofile({ pointer: "abc@v4", env: ENV_ON, fetchImpl });
|
|
191
|
-
expect(fetches).toBe(5);
|
|
190
|
+
await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
|
|
191
|
+
expect(fetches).toBe(5); // v1 evicted (cap 3) — re-fetch, never stale
|
|
192
|
+
await resolveDraftDecofile({ pointer: `${P}@v4`, env: ENV_ON, fetchImpl });
|
|
193
|
+
expect(fetches).toBe(5); // v4 resident
|
|
192
194
|
});
|
|
193
195
|
|
|
194
|
-
it("
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const blocks = await resolveDraftDecofile({
|
|
200
|
-
pointer: "abc@v1",
|
|
201
|
-
env: {
|
|
202
|
-
DECO_DRAFT_PREVIEW_HOST: "preview.example",
|
|
203
|
-
DECO_SANDBOX_ORIGIN_SUFFIXES: ".dead.example, .alive.example",
|
|
196
|
+
it("degrades to published on unreachable / non-2xx / unparseable", async () => {
|
|
197
|
+
const P = "abc.preview-studio.decocms.com";
|
|
198
|
+
for (const fetchImpl of [
|
|
199
|
+
async () => {
|
|
200
|
+
throw new Error("ECONNREFUSED");
|
|
204
201
|
},
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
pointer: "a@b@c",
|
|
222
|
-
env: ENV_ON,
|
|
223
|
-
fetchImpl: (async () => {
|
|
224
|
-
called = true;
|
|
225
|
-
return jsonResponse({});
|
|
226
|
-
}) as unknown as typeof fetch,
|
|
227
|
-
});
|
|
228
|
-
expect(blocks).toBeNull();
|
|
229
|
-
expect(called).toBe(false);
|
|
202
|
+
async () => new Response("nope", { status: 404 }),
|
|
203
|
+
async () =>
|
|
204
|
+
new Response("<html>not json</html>", {
|
|
205
|
+
status: 200,
|
|
206
|
+
headers: { "content-type": "text/html" },
|
|
207
|
+
}),
|
|
208
|
+
]) {
|
|
209
|
+
clearDraftCache();
|
|
210
|
+
expect(
|
|
211
|
+
await resolveDraftDecofile({
|
|
212
|
+
pointer: `${P}@v1`,
|
|
213
|
+
env: ENV_ON,
|
|
214
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
215
|
+
}),
|
|
216
|
+
).toBeNull();
|
|
217
|
+
}
|
|
230
218
|
});
|
|
219
|
+
});
|
|
231
220
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
221
|
+
// DEFAULT_PREVIEW_API_DOMAINS is part of the public contract — pin it.
|
|
222
|
+
describe("DEFAULT_PREVIEW_API_DOMAINS", () => {
|
|
223
|
+
it("ships the deco-operated origins, dot-prefixed", () => {
|
|
224
|
+
expect(DEFAULT_PREVIEW_API_DOMAINS).toEqual([
|
|
225
|
+
".preview-studio.decocms.com",
|
|
226
|
+
".local.studio.decocms.com",
|
|
227
|
+
".localhost",
|
|
228
|
+
]);
|
|
239
229
|
});
|
|
230
|
+
});
|
|
240
231
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
232
|
+
describe("site-block preview hosts", () => {
|
|
233
|
+
it("enables the feature from the site block alone — no env needed", () => {
|
|
234
|
+
setDraftPreviewHosts(["fila.vtex.app", "LOCALHOST:3100", 42, " "]);
|
|
235
|
+
try {
|
|
236
|
+
expect(isDraftPreviewEnabled({})).toBe(true);
|
|
237
|
+
expect(isDraftHostAllowed("fila.vtex.app", {})).toBe(true);
|
|
238
|
+
// Sanitized: lowercased, non-strings and blanks dropped.
|
|
239
|
+
expect(isDraftHostAllowed("localhost:3100", {})).toBe(true);
|
|
240
|
+
expect(isDraftHostAllowed("evil.example", {})).toBe(false);
|
|
241
|
+
} finally {
|
|
242
|
+
setDraftPreviewHosts([]);
|
|
243
|
+
}
|
|
250
244
|
});
|
|
251
245
|
|
|
252
|
-
it("
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
env:
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
246
|
+
it("env REPLACES the block hosts when set — the operational escape hatch", () => {
|
|
247
|
+
setDraftPreviewHosts(["fila.vtex.app"]);
|
|
248
|
+
try {
|
|
249
|
+
const env = { DECO_ALLOWED_PREVIEW_HOSTS: "other.example" };
|
|
250
|
+
expect(isDraftHostAllowed("other.example", env)).toBe(true);
|
|
251
|
+
// Not merged: env is a kill switch / override, so the block value must
|
|
252
|
+
// not survive alongside it.
|
|
253
|
+
expect(isDraftHostAllowed("fila.vtex.app", env)).toBe(false);
|
|
254
|
+
} finally {
|
|
255
|
+
setDraftPreviewHosts([]);
|
|
256
|
+
}
|
|
263
257
|
});
|
|
264
258
|
});
|
package/src/cms/draftSource.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Draft preview — pull-based decofile override.
|
|
3
3
|
*
|
|
4
|
-
* A Studio
|
|
4
|
+
* A Studio preview serves the working-tree draft at
|
|
5
5
|
* `GET <origin>/_sandbox/decofile`; a production site pulls it and renders its
|
|
6
6
|
* own real pages against it. This replaces pushing the decofile into a POST
|
|
7
7
|
* body, which only deco's own runtime honours — Next.js and most frameworks
|
|
8
8
|
* render on GET only.
|
|
9
9
|
*
|
|
10
|
-
* This module is the framework-agnostic half:
|
|
11
|
-
*
|
|
10
|
+
* This module is the framework-agnostic half: token parsing, origin
|
|
11
|
+
* validation, fetching, and version caching. Binding a resolved draft to a
|
|
12
12
|
* request is framework-specific (see `@decocms/nextjs`'s draft wiring) and
|
|
13
13
|
* reaches this module through {@link setDraftOverrideGetter} — the same
|
|
14
14
|
* dependency-injection shape as `setFastDeployKVGetter`, so `blocks` keeps its
|
|
15
15
|
* zero-dependency direction.
|
|
16
16
|
*
|
|
17
|
-
* Inert unless `
|
|
17
|
+
* Inert unless `DECO_ALLOWED_PREVIEW_HOSTS` names the request's host: upgrading
|
|
18
18
|
* the package must never be enough to start fetching from the network and
|
|
19
19
|
* rendering unpublished content. Host-scoping (rather than a boolean) exists
|
|
20
20
|
* because one deployment commonly serves several domains — the preview domain
|
|
@@ -22,80 +22,132 @@
|
|
|
22
22
|
* ignore a `?__draft=` entirely.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* A parsed `?__draft=` token: `<host[:port]>@<version>`.
|
|
27
|
+
*
|
|
28
|
+
* The token carries the AUTHORITY of the draft content API, never a scheme or
|
|
29
|
+
* path — a full URL would be an SSRF vector, and the scheme is derived from
|
|
30
|
+
* the matched domain instead. Reserved evolution: a future signed token uses a
|
|
31
|
+
* distinguishable prefix (e.g. `s1.`), so this strict two-part parse rejects
|
|
32
|
+
* it cleanly rather than half-reading it.
|
|
33
|
+
*/
|
|
26
34
|
export interface DraftPointer {
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
/**
|
|
35
|
+
/** Content-API authority, e.g. `abc.preview-studio.decocms.com` or `abc.localhost:60534`. */
|
|
36
|
+
host: string;
|
|
37
|
+
/** Opaque content version (the server's ETag). Immutable → safe cache key. */
|
|
30
38
|
version: string;
|
|
31
39
|
}
|
|
32
40
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
* smuggle `/`, `@`, `:` or userinfo into the URL and redirect the fetch at some
|
|
38
|
-
* other host.
|
|
39
|
-
*/
|
|
40
|
-
const HANDLE_RE = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/;
|
|
41
|
+
/** Lowercase DNS hostname, at least two labels (a bare label can't match any domain). */
|
|
42
|
+
const HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
43
|
+
const PORT_RE = /^[0-9]{1,5}$/;
|
|
44
|
+
const VERSION_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
41
45
|
|
|
42
46
|
/**
|
|
43
|
-
* Parse `<
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* uses the first two segments, which is how a malformed pointer sneaks past
|
|
47
|
-
* validation. Returns null on anything unexpected — callers fall back to
|
|
48
|
-
* published content.
|
|
47
|
+
* Parse `<host[:port]>@<version>`. Null on anything unexpected — callers fall
|
|
48
|
+
* back to published content. Requires EXACTLY one `@`: a naive split accepts
|
|
49
|
+
* `a@b@c` and silently uses the first two segments.
|
|
49
50
|
*/
|
|
50
51
|
export function parseDraftPointer(raw: string | null | undefined): DraftPointer | null {
|
|
51
52
|
if (!raw) return null;
|
|
52
53
|
const parts = raw.split("@");
|
|
53
54
|
if (parts.length !== 2) return null;
|
|
54
|
-
const [
|
|
55
|
-
if (!
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
const [authority, version] = parts;
|
|
56
|
+
if (!authority || !version || !VERSION_RE.test(version)) return null;
|
|
57
|
+
|
|
58
|
+
const [host, port, extra] = authority.toLowerCase().split(":");
|
|
59
|
+
if (extra !== undefined) return null;
|
|
60
|
+
if (!host || !HOST_RE.test(host)) return null;
|
|
61
|
+
if (port !== undefined && !PORT_RE.test(port)) return null;
|
|
62
|
+
|
|
63
|
+
return { host: port === undefined ? host : `${host}:${port}`, version };
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
/**
|
|
61
|
-
*
|
|
62
|
-
* defaults adds no SSRF surface
|
|
63
|
-
*
|
|
64
|
-
*
|
|
67
|
+
* Domains the draft content API may live under — deco-operated, so shipping
|
|
68
|
+
* them as defaults adds no SSRF surface. `DECO_PREVIEW_API_DOMAINS` overrides
|
|
69
|
+
* the whole list when set. Entries are dot-prefixed suffixes, which guarantees
|
|
70
|
+
* a label boundary on match (`evil-preview-studio.decocms.com` cannot pass).
|
|
65
71
|
*/
|
|
66
|
-
export const
|
|
72
|
+
export const DEFAULT_PREVIEW_API_DOMAINS = [
|
|
67
73
|
".preview-studio.decocms.com",
|
|
68
74
|
".local.studio.decocms.com",
|
|
69
75
|
".localhost",
|
|
70
76
|
];
|
|
71
77
|
|
|
78
|
+
function readApiDomains(env: Record<string, string | undefined>): string[] {
|
|
79
|
+
const configured = (env.DECO_PREVIEW_API_DOMAINS ?? "")
|
|
80
|
+
.split(",")
|
|
81
|
+
.map((s) => s.trim().toLowerCase())
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
return configured.length > 0 ? configured : DEFAULT_PREVIEW_API_DOMAINS;
|
|
84
|
+
}
|
|
85
|
+
|
|
72
86
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
87
|
+
* Validate the token's authority against the configured domains and derive the
|
|
88
|
+
* fetch origin, or null if no domain admits it.
|
|
89
|
+
*
|
|
90
|
+
* The token proposes, configuration disposes: only the hostname-suffix match
|
|
91
|
+
* decides, so a caller can steer WHICH label under your domains, never which
|
|
92
|
+
* domains. Scheme is derived — http for localhost-ish domains, https
|
|
93
|
+
* otherwise — and an explicit port is allowed only there, so a public-domain
|
|
94
|
+
* token cannot aim at odd ports.
|
|
76
95
|
*/
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
96
|
+
export function previewApiOriginForHost(
|
|
97
|
+
authority: string,
|
|
98
|
+
env?: Record<string, string | undefined>,
|
|
99
|
+
): string | null {
|
|
100
|
+
const [host, port] = authority.toLowerCase().split(":");
|
|
101
|
+
if (!host) return null;
|
|
102
|
+
const domain = readApiDomains(envOrProcess(env)).find(
|
|
103
|
+
(d) => host.length > d.length && host.endsWith(d),
|
|
104
|
+
);
|
|
105
|
+
if (!domain) return null;
|
|
106
|
+
const local = domain.includes("localhost");
|
|
107
|
+
if (port !== undefined && !local) return null;
|
|
108
|
+
return `${local ? "http" : "https"}://${host}${port === undefined ? "" : `:${port}`}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Hosts declared by the site itself (the global `site` block's `previewHosts`),
|
|
113
|
+
* installed once at setup time by the framework binding.
|
|
114
|
+
*
|
|
115
|
+
* MUST be fed from the setup-time base blocks, never from `loadBlocks()` at
|
|
116
|
+
* request time: the request path merges the draft override, and an allowlist
|
|
117
|
+
* readable through the override could be rewritten by the very draft it gates.
|
|
118
|
+
*/
|
|
119
|
+
let sitePreviewHosts: string[] = [];
|
|
120
|
+
|
|
121
|
+
/** Install the site-declared preview hosts. Called by the framework binding at setup. */
|
|
122
|
+
export function setDraftPreviewHosts(hosts: readonly unknown[]): void {
|
|
123
|
+
sitePreviewHosts = hosts
|
|
124
|
+
.filter((h): h is string => typeof h === "string")
|
|
125
|
+
.map((h) => h.trim().toLowerCase())
|
|
81
126
|
.filter(Boolean);
|
|
82
|
-
return configured.length > 0 ? configured : DEFAULT_SANDBOX_ORIGIN_SUFFIXES;
|
|
83
127
|
}
|
|
84
128
|
|
|
85
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Hosts allowed to render drafts.
|
|
131
|
+
*
|
|
132
|
+
* The site block is the expected source — the opt-in lives in the repo,
|
|
133
|
+
* reviewed in a PR, versioned with branches. `DECO_ALLOWED_PREVIEW_HOSTS`
|
|
134
|
+
* REPLACES it when set: an operational escape hatch (kill a bad value without
|
|
135
|
+
* a deploy, add a machine-specific port) — not the primary configuration.
|
|
136
|
+
*/
|
|
86
137
|
function readAllowedHosts(env: Record<string, string | undefined>): string[] {
|
|
87
|
-
|
|
138
|
+
const fromEnv = (env.DECO_ALLOWED_PREVIEW_HOSTS ?? "")
|
|
88
139
|
.split(",")
|
|
89
140
|
.map((s) => s.trim().toLowerCase())
|
|
90
141
|
.filter(Boolean);
|
|
142
|
+
return fromEnv.length > 0 ? fromEnv : sitePreviewHosts;
|
|
91
143
|
}
|
|
92
144
|
|
|
93
145
|
/**
|
|
94
146
|
* Whether `host` (as seen on the request) may render drafts.
|
|
95
147
|
*
|
|
96
|
-
* Compared against `
|
|
97
|
-
* dev is `localhost:3100`, not `localhost`. The header is spoofable by a
|
|
98
|
-
* direct-to-origin request, but the
|
|
148
|
+
* Compared against `DECO_ALLOWED_PREVIEW_HOSTS` verbatim, port included —
|
|
149
|
+
* local dev is `localhost:3100`, not `localhost`. The header is spoofable by a
|
|
150
|
+
* direct-to-origin request, but the draft id is the actual capability;
|
|
99
151
|
* host-scoping bounds blast radius (production domains stay inert), it is not
|
|
100
152
|
* a secret.
|
|
101
153
|
*/
|
|
@@ -104,8 +156,17 @@ export function isDraftHostAllowed(
|
|
|
104
156
|
env?: Record<string, string | undefined>,
|
|
105
157
|
): boolean {
|
|
106
158
|
if (!host) return false;
|
|
107
|
-
|
|
108
|
-
|
|
159
|
+
return readAllowedHosts(envOrProcess(env)).includes(host.trim().toLowerCase());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* True when any host is allowed to preview. A plain env read — callers use it
|
|
164
|
+
* to gate BEFORE touching dynamic APIs (`cookies()`/`headers()`), so an
|
|
165
|
+
* unconfigured site never loses static/ISR rendering. The per-request host
|
|
166
|
+
* match happens later, in `isDraftHostAllowed`.
|
|
167
|
+
*/
|
|
168
|
+
export function isDraftPreviewEnabled(env?: Record<string, string | undefined>): boolean {
|
|
169
|
+
return readAllowedHosts(envOrProcess(env)).length > 0;
|
|
109
170
|
}
|
|
110
171
|
|
|
111
172
|
function envOrProcess(
|
|
@@ -119,31 +180,14 @@ function envOrProcess(
|
|
|
119
180
|
}
|
|
120
181
|
|
|
121
182
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* SSRF surface to defend and no allowlist to keep correct. A `localhost`
|
|
126
|
-
* suffix (local e2e) speaks http; everything else is https.
|
|
127
|
-
*/
|
|
128
|
-
export function buildDraftOrigin(handle: string, suffix: string): string | null {
|
|
129
|
-
if (!suffix) return null;
|
|
130
|
-
if (!HANDLE_RE.test(handle)) return null;
|
|
131
|
-
const scheme = suffix.includes("localhost") ? "http" : "https";
|
|
132
|
-
return `${scheme}://${handle}${suffix}`;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Version cache.
|
|
137
|
-
*
|
|
138
|
-
* Bounded on purpose: a decofile is routinely multi-megabyte, so an unbounded
|
|
139
|
-
* map keyed by version would grow with every save until the process died.
|
|
140
|
-
* Keyed by version (content-addressed), so a hit is always correct.
|
|
183
|
+
* Version cache. Bounded on purpose: a decofile is routinely multi-megabyte,
|
|
184
|
+
* so an unbounded map keyed by version would grow with every save until the
|
|
185
|
+
* process died. Content-addressed, so a hit is always correct.
|
|
141
186
|
*/
|
|
142
187
|
const MAX_CACHED_VERSIONS = 3;
|
|
143
188
|
const byVersion = new Map<string, Record<string, unknown>>();
|
|
144
189
|
|
|
145
190
|
function cacheDraft(version: string, blocks: Record<string, unknown>): void {
|
|
146
|
-
// Re-insert to make this the most recently used key.
|
|
147
191
|
byVersion.delete(version);
|
|
148
192
|
byVersion.set(version, blocks);
|
|
149
193
|
while (byVersion.size > MAX_CACHED_VERSIONS) {
|
|
@@ -159,7 +203,7 @@ export function clearDraftCache(): void {
|
|
|
159
203
|
}
|
|
160
204
|
|
|
161
205
|
export interface ResolveDraftOptions {
|
|
162
|
-
/** Raw `<
|
|
206
|
+
/** Raw `<host[:port]>@<version>` token from the request. */
|
|
163
207
|
pointer: string | null | undefined;
|
|
164
208
|
/** Defaults to `process.env`. */
|
|
165
209
|
env?: Record<string, string | undefined>;
|
|
@@ -168,14 +212,11 @@ export interface ResolveDraftOptions {
|
|
|
168
212
|
}
|
|
169
213
|
|
|
170
214
|
/**
|
|
171
|
-
* Resolve a draft
|
|
215
|
+
* Resolve a draft token to a decofile, or null to render published content.
|
|
172
216
|
*
|
|
173
|
-
* Null on every failure path — disabled, malformed
|
|
174
|
-
*
|
|
175
|
-
* published rather than break the page.
|
|
176
|
-
* the draft failed should check {@link isDraftPreviewEnabled} and surface it
|
|
177
|
-
* themselves; silently showing published content while the user believes they
|
|
178
|
-
* are looking at a draft is the failure mode worth avoiding.
|
|
217
|
+
* Null on every failure path — disabled, malformed token, disallowed origin,
|
|
218
|
+
* unreachable, non-2xx — because a draft that cannot be resolved must degrade
|
|
219
|
+
* to published rather than break the page.
|
|
179
220
|
*/
|
|
180
221
|
export async function resolveDraftDecofile(
|
|
181
222
|
options: ResolveDraftOptions,
|
|
@@ -189,44 +230,27 @@ export async function resolveDraftDecofile(
|
|
|
189
230
|
const cached = byVersion.get(parsed.version);
|
|
190
231
|
if (cached) return cached;
|
|
191
232
|
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
// wins. A miss on one origin (unreachable, non-2xx, garbage) is expected —
|
|
195
|
-
// the handle only exists under one of them — so every failure falls through
|
|
196
|
-
// to the next rather than aborting the resolve.
|
|
197
|
-
for (const suffix of readSuffixes(env)) {
|
|
198
|
-
const origin = buildDraftOrigin(parsed.handle, suffix);
|
|
199
|
-
if (!origin) continue;
|
|
200
|
-
|
|
201
|
-
let res: Response;
|
|
202
|
-
try {
|
|
203
|
-
res = await doFetch(`${origin}/_sandbox/decofile`, { cache: "no-store" });
|
|
204
|
-
} catch {
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
if (!res.ok) continue;
|
|
233
|
+
const origin = previewApiOriginForHost(parsed.host, env);
|
|
234
|
+
if (!origin) return null;
|
|
208
235
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
|
|
236
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
237
|
+
let res: Response;
|
|
238
|
+
try {
|
|
239
|
+
res = await doFetch(`${origin}/_sandbox/decofile`, { cache: "no-store" });
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
if (!res.ok) return null;
|
|
215
244
|
|
|
216
|
-
|
|
217
|
-
|
|
245
|
+
let blocks: Record<string, unknown>;
|
|
246
|
+
try {
|
|
247
|
+
blocks = (await res.json()) as Record<string, unknown>;
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
218
250
|
}
|
|
219
|
-
return null;
|
|
220
|
-
}
|
|
221
251
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
* to gate BEFORE touching dynamic APIs (`cookies()`/`headers()`), so an
|
|
225
|
-
* unconfigured site never loses static/ISR rendering. The per-request host
|
|
226
|
-
* match happens later, in `isDraftHostAllowed`.
|
|
227
|
-
*/
|
|
228
|
-
export function isDraftPreviewEnabled(env?: Record<string, string | undefined>): boolean {
|
|
229
|
-
return readAllowedHosts(envOrProcess(env)).length > 0;
|
|
252
|
+
cacheDraft(parsed.version, blocks);
|
|
253
|
+
return blocks;
|
|
230
254
|
}
|
|
231
255
|
|
|
232
256
|
// ---------------------------------------------------------------------------
|
|
@@ -242,10 +266,8 @@ let getDraftOverride: DraftOverrideGetter = () => undefined;
|
|
|
242
266
|
*
|
|
243
267
|
* Binding a value to "the current request" is framework-specific and `blocks`
|
|
244
268
|
* must not know about any framework: `@decocms/nextjs` backs this with React
|
|
245
|
-
* `cache()` (App Router has no AsyncLocalStorage request scope of its own
|
|
246
|
-
*
|
|
247
|
-
* request scope can back it with that instead. Never called → returns
|
|
248
|
-
* undefined → `loadBlocks()` behaves exactly as before.
|
|
269
|
+
* `cache()` (App Router has no AsyncLocalStorage request scope of its own).
|
|
270
|
+
* Never called → returns undefined → `loadBlocks()` behaves exactly as before.
|
|
249
271
|
*/
|
|
250
272
|
export function setDraftOverrideGetter(getter: DraftOverrideGetter): void {
|
|
251
273
|
getDraftOverride = getter;
|
package/src/cms/index.ts
CHANGED
|
@@ -14,15 +14,16 @@ export {
|
|
|
14
14
|
} from "./blockSource";
|
|
15
15
|
export type { DraftPointer, ResolveDraftOptions } from "./draftSource";
|
|
16
16
|
export {
|
|
17
|
-
buildDraftOrigin,
|
|
18
17
|
clearDraftCache,
|
|
19
|
-
|
|
18
|
+
DEFAULT_PREVIEW_API_DOMAINS,
|
|
20
19
|
getRequestDraftOverride,
|
|
21
20
|
isDraftHostAllowed,
|
|
22
21
|
isDraftPreviewEnabled,
|
|
23
22
|
parseDraftPointer,
|
|
23
|
+
previewApiOriginForHost,
|
|
24
24
|
resolveDraftDecofile,
|
|
25
25
|
setDraftOverrideGetter,
|
|
26
|
+
setDraftPreviewHosts,
|
|
26
27
|
} from "./draftSource";
|
|
27
28
|
export type { DecoPage, Resolvable } from "./loader";
|
|
28
29
|
export {
|
package/src/cms/loader.test.ts
CHANGED
|
@@ -82,12 +82,12 @@ describe("matchPath", () => {
|
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
it("matches with an optional prefix before a literal segment", () => {
|
|
85
|
-
expect(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
expect(
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
expect(matchPath("/{granado/}?campanhas/*", "/granado/campanhas/destaques-2023")).toEqual({
|
|
86
|
+
"0": "destaques-2023",
|
|
87
|
+
});
|
|
88
|
+
expect(matchPath("/{granado/}?campanhas/*", "/campanhas/destaques-2023")).toEqual({
|
|
89
|
+
"0": "destaques-2023",
|
|
90
|
+
});
|
|
91
91
|
});
|
|
92
92
|
|
|
93
93
|
it("matches an optional suffix group present and absent", () => {
|
|
@@ -112,9 +112,7 @@ describe("matchPath", () => {
|
|
|
112
112
|
// biome-ignore lint/performance/noDelete: restoring exact global state
|
|
113
113
|
delete g.URLPattern;
|
|
114
114
|
try {
|
|
115
|
-
expect(() => matchPath("/foo/:slug", "/foo/bar")).toThrow(
|
|
116
|
-
/URLPattern.*Node\.js >= 24/s,
|
|
117
|
-
);
|
|
115
|
+
expect(() => matchPath("/foo/:slug", "/foo/bar")).toThrow(/URLPattern.*Node\.js >= 24/s);
|
|
118
116
|
} finally {
|
|
119
117
|
if (saved !== undefined) g.URLPattern = saved;
|
|
120
118
|
}
|
package/src/cms/registry.test.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { describe, expect, it
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
registerSectionsSync,
|
|
3
|
+
getResolvedComponent,
|
|
5
4
|
getSection,
|
|
6
5
|
getSectionOptions,
|
|
7
6
|
getSyncComponent,
|
|
8
|
-
|
|
7
|
+
registerSection,
|
|
8
|
+
registerSectionsSync,
|
|
9
9
|
} from "./registry";
|
|
10
10
|
|
|
11
11
|
// Reset globalThis.__deco between tests to avoid cross-test pollution
|
|
@@ -34,44 +34,36 @@ describe("registerSection + getSection", () => {
|
|
|
34
34
|
describe("registerSection with options", () => {
|
|
35
35
|
it("stores loadingFallback in section options", () => {
|
|
36
36
|
const fallback = () => null;
|
|
37
|
-
registerSection(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
{ loadingFallback: fallback },
|
|
41
|
-
);
|
|
37
|
+
registerSection("site/sections/Shelf.tsx", async () => ({ default: () => null }), {
|
|
38
|
+
loadingFallback: fallback,
|
|
39
|
+
});
|
|
42
40
|
|
|
43
41
|
const opts = getSectionOptions("site/sections/Shelf.tsx");
|
|
44
42
|
expect(opts?.loadingFallback).toBe(fallback);
|
|
45
43
|
});
|
|
46
44
|
|
|
47
45
|
it("stores clientOnly flag in section options", () => {
|
|
48
|
-
registerSection(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{ clientOnly: true },
|
|
52
|
-
);
|
|
46
|
+
registerSection("site/sections/Analytics.tsx", async () => ({ default: () => null }), {
|
|
47
|
+
clientOnly: true,
|
|
48
|
+
});
|
|
53
49
|
|
|
54
50
|
const opts = getSectionOptions("site/sections/Analytics.tsx");
|
|
55
51
|
expect(opts?.clientOnly).toBe(true);
|
|
56
52
|
});
|
|
57
53
|
|
|
58
54
|
it("clientOnly defaults to undefined when not set", () => {
|
|
59
|
-
registerSection(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
{ loadingFallback: () => null },
|
|
63
|
-
);
|
|
55
|
+
registerSection("site/sections/Normal.tsx", async () => ({ default: () => null }), {
|
|
56
|
+
loadingFallback: () => null,
|
|
57
|
+
});
|
|
64
58
|
|
|
65
59
|
const opts = getSectionOptions("site/sections/Normal.tsx");
|
|
66
60
|
expect(opts?.clientOnly).toBeUndefined();
|
|
67
61
|
});
|
|
68
62
|
|
|
69
63
|
it("clientOnly false is preserved", () => {
|
|
70
|
-
registerSection(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
{ clientOnly: false },
|
|
74
|
-
);
|
|
64
|
+
registerSection("site/sections/Explicit.tsx", async () => ({ default: () => null }), {
|
|
65
|
+
clientOnly: false,
|
|
66
|
+
});
|
|
75
67
|
|
|
76
68
|
const opts = getSectionOptions("site/sections/Explicit.tsx");
|
|
77
69
|
expect(opts?.clientOnly).toBe(false);
|
package/src/cms/registry.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { ComponentType } from "react";
|
|
2
2
|
|
|
3
|
-
export type OnBeforeResolveProps = (
|
|
4
|
-
props: Record<string, unknown>,
|
|
5
|
-
) => Record<string, unknown>;
|
|
3
|
+
export type OnBeforeResolveProps = (props: Record<string, unknown>) => Record<string, unknown>;
|
|
6
4
|
|
|
7
5
|
export type SectionModule = {
|
|
8
6
|
default: ComponentType<any>;
|
|
@@ -35,8 +33,7 @@ if (!G.__deco.sectionRegistry) G.__deco.sectionRegistry = {};
|
|
|
35
33
|
if (!G.__deco.sectionOptions) G.__deco.sectionOptions = {};
|
|
36
34
|
if (!G.__deco.resolvedComponents) G.__deco.resolvedComponents = {};
|
|
37
35
|
if (!G.__deco.syncComponents) G.__deco.syncComponents = {};
|
|
38
|
-
if (!G.__deco.onBeforeResolvePropsRegistry)
|
|
39
|
-
G.__deco.onBeforeResolvePropsRegistry = {};
|
|
36
|
+
if (!G.__deco.onBeforeResolvePropsRegistry) G.__deco.onBeforeResolvePropsRegistry = {};
|
|
40
37
|
|
|
41
38
|
const registry: Record<string, RegistryEntry> = G.__deco.sectionRegistry;
|
|
42
39
|
const sectionOptions: Record<string, SectionOptions> = G.__deco.sectionOptions;
|
|
@@ -194,9 +191,7 @@ export function registerSectionsSync(sections: Record<string, SyncSectionEntry>)
|
|
|
194
191
|
];
|
|
195
192
|
const component =
|
|
196
193
|
typeof raw === "function" ||
|
|
197
|
-
(raw != null &&
|
|
198
|
-
typeof raw === "object" &&
|
|
199
|
-
REACT_WRAPPERS.includes((raw as any).$$typeof))
|
|
194
|
+
(raw != null && typeof raw === "object" && REACT_WRAPPERS.includes((raw as any).$$typeof))
|
|
200
195
|
? raw
|
|
201
196
|
: undefined;
|
|
202
197
|
if (!component) {
|
|
@@ -229,17 +224,12 @@ export function getSyncComponent(key: string): ComponentType<any> | undefined {
|
|
|
229
224
|
* BEFORE the resolution engine resolves them. Use to extract metadata from
|
|
230
225
|
* resolvable structures that would be lost after resolution.
|
|
231
226
|
*/
|
|
232
|
-
export function registerOnBeforeResolveProps(
|
|
233
|
-
sectionKey: string,
|
|
234
|
-
fn: OnBeforeResolveProps,
|
|
235
|
-
): void {
|
|
227
|
+
export function registerOnBeforeResolveProps(sectionKey: string, fn: OnBeforeResolveProps): void {
|
|
236
228
|
onBeforeResolvePropsRegistry[sectionKey] = fn;
|
|
237
229
|
}
|
|
238
230
|
|
|
239
231
|
/** Get the registered onBeforeResolveProps for a section, if any. */
|
|
240
|
-
export function getOnBeforeResolveProps(
|
|
241
|
-
sectionKey: string,
|
|
242
|
-
): OnBeforeResolveProps | undefined {
|
|
232
|
+
export function getOnBeforeResolveProps(sectionKey: string): OnBeforeResolveProps | undefined {
|
|
243
233
|
return onBeforeResolvePropsRegistry[sectionKey];
|
|
244
234
|
}
|
|
245
235
|
|
package/src/cms/resolve.test.ts
CHANGED
|
@@ -20,6 +20,10 @@ vi.mock("./registry", () => ({
|
|
|
20
20
|
getOnBeforeResolveProps: vi.fn(),
|
|
21
21
|
}));
|
|
22
22
|
|
|
23
|
+
import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
|
|
24
|
+
import { findPageByPath } from "./loader";
|
|
25
|
+
import { getSection } from "./registry";
|
|
26
|
+
import type { AsyncRenderingConfig, DeferredSection } from "./resolve";
|
|
23
27
|
import {
|
|
24
28
|
clearCommerceLoaders,
|
|
25
29
|
DEFAULT_FOLD_THRESHOLD,
|
|
@@ -29,8 +33,8 @@ import {
|
|
|
29
33
|
registerCommerceLoader,
|
|
30
34
|
registerEagerSections,
|
|
31
35
|
registerNeverDeferSections,
|
|
32
|
-
resolveDeferredSectionFull,
|
|
33
36
|
resolveDecoPage,
|
|
37
|
+
resolveDeferredSectionFull,
|
|
34
38
|
resolvePageSeoBlock,
|
|
35
39
|
resolveSectionsList,
|
|
36
40
|
resolveValue,
|
|
@@ -39,10 +43,6 @@ import {
|
|
|
39
43
|
WELL_KNOWN_TYPES,
|
|
40
44
|
} from "./resolve";
|
|
41
45
|
import { runSingleSectionLoader } from "./sectionLoaders";
|
|
42
|
-
import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
|
|
43
|
-
import { findPageByPath } from "./loader";
|
|
44
|
-
import { getSection } from "./registry";
|
|
45
|
-
import type { AsyncRenderingConfig, DeferredSection } from "./resolve";
|
|
46
46
|
|
|
47
47
|
describe("resolveDeferredSectionFull", () => {
|
|
48
48
|
it("resolves a deferred section and preserves index", async () => {
|
|
@@ -217,14 +217,10 @@ describe("commerce loader auto-injects URL search params as props", () => {
|
|
|
217
217
|
return null;
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
-
await resolveValue(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
url: "https://store.com/produto/sabonete/p?skuId=12345&size=M",
|
|
225
|
-
path: "/produto/sabonete/p",
|
|
226
|
-
},
|
|
227
|
-
);
|
|
220
|
+
await resolveValue({ __resolveType: KEY, slug: "sabonete" }, undefined, {
|
|
221
|
+
url: "https://store.com/produto/sabonete/p?skuId=12345&size=M",
|
|
222
|
+
path: "/produto/sabonete/p",
|
|
223
|
+
});
|
|
228
224
|
|
|
229
225
|
expect(calls).toHaveLength(1);
|
|
230
226
|
expect(calls[0]).toMatchObject({
|
|
@@ -243,11 +239,10 @@ describe("commerce loader auto-injects URL search params as props", () => {
|
|
|
243
239
|
return null;
|
|
244
240
|
});
|
|
245
241
|
|
|
246
|
-
await resolveValue(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
);
|
|
242
|
+
await resolveValue({ __resolveType: KEY, skuId: "cms-locked-sku" }, undefined, {
|
|
243
|
+
url: "https://store.com/p?skuId=url-value",
|
|
244
|
+
path: "/p",
|
|
245
|
+
});
|
|
251
246
|
|
|
252
247
|
expect(calls[0]?.skuId).toBe("cms-locked-sku");
|
|
253
248
|
});
|
|
@@ -358,7 +353,10 @@ describe("commerce loader resolves legacy .ts-suffixed resolveType", () => {
|
|
|
358
353
|
await resolveValue(
|
|
359
354
|
{ __resolveType: "shopify/loaders/ProductDetailsPage.ts", slug: "oversize-t-shirt-123" },
|
|
360
355
|
undefined,
|
|
361
|
-
{
|
|
356
|
+
{
|
|
357
|
+
url: "https://store.com/products/oversize-t-shirt-123",
|
|
358
|
+
path: "/products/oversize-t-shirt-123",
|
|
359
|
+
},
|
|
362
360
|
);
|
|
363
361
|
|
|
364
362
|
expect(calls).toHaveLength(1);
|
|
@@ -509,9 +507,9 @@ describe("isEagerRequest — programmatic fetch detection", () => {
|
|
|
509
507
|
});
|
|
510
508
|
|
|
511
509
|
it("falls back to matcherCtx.headers when no Request is present", () => {
|
|
512
|
-
expect(
|
|
513
|
-
|
|
514
|
-
)
|
|
510
|
+
expect(isEagerRequest({ userAgent: HUMAN_UA, headers: { "sec-fetch-dest": "empty" } })).toBe(
|
|
511
|
+
true,
|
|
512
|
+
);
|
|
515
513
|
});
|
|
516
514
|
|
|
517
515
|
it("a request with no Sec-Fetch headers stays deferred (no UA bot, no override)", () => {
|
package/src/cms/resolve.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type ActionConfig,
|
|
3
|
-
inferLoaderTags,
|
|
4
|
-
type LoaderConfig,
|
|
5
|
-
registerActionSchemas,
|
|
6
|
-
registerLoaderSchemas,
|
|
7
|
-
} from "./schema";
|
|
8
1
|
import { getMatchersOverride, getRuleOverrideId, hasMatchersOverride } from "../matchers/override";
|
|
9
2
|
import { getMeter, MetricNames, withTracing } from "../middleware/observability";
|
|
10
3
|
import { djb2Hex } from "../sdk/djb2";
|
|
@@ -13,6 +6,13 @@ import { withInflightTimeout } from "../sdk/inflightTimeout";
|
|
|
13
6
|
import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
|
|
14
7
|
import { findPageByPath, loadBlocks } from "./loader";
|
|
15
8
|
import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } from "./registry";
|
|
9
|
+
import {
|
|
10
|
+
type ActionConfig,
|
|
11
|
+
inferLoaderTags,
|
|
12
|
+
type LoaderConfig,
|
|
13
|
+
registerActionSchemas,
|
|
14
|
+
registerLoaderSchemas,
|
|
15
|
+
} from "./schema";
|
|
16
16
|
import { isLayoutSection, markSectionDegraded, runSingleSectionLoader } from "./sectionLoaders";
|
|
17
17
|
|
|
18
18
|
// globalThis-backed: share state across Vite server function split modules
|
|
@@ -227,7 +227,7 @@ function isEagerSection(key: string): boolean {
|
|
|
227
227
|
* during hydration (search filters, configurators, etc.).
|
|
228
228
|
*/
|
|
229
229
|
export function registerNeverDeferSections(keys: string[]): void {
|
|
230
|
-
const set: Set<string> = G.__deco.neverDeferSectionKeys ??= new Set();
|
|
230
|
+
const set: Set<string> = (G.__deco.neverDeferSectionKeys ??= new Set());
|
|
231
231
|
for (const k of keys) set.add(k);
|
|
232
232
|
}
|
|
233
233
|
|
|
@@ -342,8 +342,7 @@ function hasForceEagerParam(ctx?: MatcherContext): boolean {
|
|
|
342
342
|
*/
|
|
343
343
|
function isProgrammaticFetch(ctx?: MatcherContext): boolean {
|
|
344
344
|
if (ctx?.isClientNavigation) return false;
|
|
345
|
-
const dest = ctx?.request?.headers.get("sec-fetch-dest") ??
|
|
346
|
-
ctx?.headers?.["sec-fetch-dest"];
|
|
345
|
+
const dest = ctx?.request?.headers.get("sec-fetch-dest") ?? ctx?.headers?.["sec-fetch-dest"];
|
|
347
346
|
return dest === "empty";
|
|
348
347
|
}
|
|
349
348
|
|
|
@@ -354,8 +353,7 @@ function isProgrammaticFetch(ctx?: MatcherContext): boolean {
|
|
|
354
353
|
* to gate both section deferral and page-SEO commerce resolution.
|
|
355
354
|
*/
|
|
356
355
|
export function isEagerRequest(ctx?: MatcherContext): boolean {
|
|
357
|
-
return isBot(ctx?.userAgent) || hasForceEagerParam(ctx) ||
|
|
358
|
-
isProgrammaticFetch(ctx);
|
|
356
|
+
return isBot(ctx?.userAgent) || hasForceEagerParam(ctx) || isProgrammaticFetch(ctx);
|
|
359
357
|
}
|
|
360
358
|
|
|
361
359
|
/**
|
|
@@ -735,7 +733,9 @@ function evaluateVariantRule(
|
|
|
735
733
|
const already = ctx.flags?.find((f) => f.name === meta.name && f.pct === meta.pct);
|
|
736
734
|
if (already) return already.value;
|
|
737
735
|
|
|
738
|
-
const stored = parseSegmentCookie(ctx.cookies?.[SEGMENT_COOKIE]).find(
|
|
736
|
+
const stored = parseSegmentCookie(ctx.cookies?.[SEGMENT_COOKIE]).find(
|
|
737
|
+
(f) => f.name === meta.name,
|
|
738
|
+
);
|
|
739
739
|
// pct === -1 marks a classic-deco segment without a fingerprint — honor it
|
|
740
740
|
// (stay sticky) instead of re-rolling. A stale fingerprint re-rolls.
|
|
741
741
|
const useStored = stored && (stored.pct === -1 || stored.pct === meta.pct);
|
|
@@ -1264,9 +1264,7 @@ function resolvesToCommerceLoader(value: unknown, depth = 0): boolean {
|
|
|
1264
1264
|
* serialized into the HTML. Lightweight literal props (title, description,
|
|
1265
1265
|
* canonical, …) are preserved.
|
|
1266
1266
|
*/
|
|
1267
|
-
function stripCommerceLoaderProps(
|
|
1268
|
-
rawProps: Record<string, unknown>,
|
|
1269
|
-
): Record<string, unknown> {
|
|
1267
|
+
function stripCommerceLoaderProps(rawProps: Record<string, unknown>): Record<string, unknown> {
|
|
1270
1268
|
const out: Record<string, unknown> = {};
|
|
1271
1269
|
for (const [k, v] of Object.entries(rawProps)) {
|
|
1272
1270
|
if (resolvesToCommerceLoader(v)) continue;
|
|
@@ -1785,8 +1783,7 @@ function deriveCommerceSeoFromJsonLD(seo: PageSeo, props: Record<string, unknown
|
|
|
1785
1783
|
const configJsonLD = props.configJsonLD as
|
|
1786
1784
|
| { ignoreStructuredData?: boolean; removeVideos?: boolean }
|
|
1787
1785
|
| undefined;
|
|
1788
|
-
const ignore =
|
|
1789
|
-
props.ignoreStructuredData === true || configJsonLD?.ignoreStructuredData === true;
|
|
1786
|
+
const ignore = props.ignoreStructuredData === true || configJsonLD?.ignoreStructuredData === true;
|
|
1790
1787
|
if (ignore || isEmpty) return;
|
|
1791
1788
|
|
|
1792
1789
|
seo.jsonLDs = [
|
package/src/cms/schema.ts
CHANGED
|
@@ -1089,10 +1089,7 @@ export interface ComposeMetaOptions {
|
|
|
1089
1089
|
framework?: string;
|
|
1090
1090
|
}
|
|
1091
1091
|
|
|
1092
|
-
export function composeMeta(
|
|
1093
|
-
siteMeta: MetaResponse,
|
|
1094
|
-
options?: ComposeMetaOptions,
|
|
1095
|
-
): MetaResponse {
|
|
1092
|
+
export function composeMeta(siteMeta: MetaResponse, options?: ComposeMetaOptions): MetaResponse {
|
|
1096
1093
|
// Idempotency guard. composeMeta is NOT structurally idempotent — it appends
|
|
1097
1094
|
// the framework section refs to `root.sections.anyOf` (and `__SECTION_REF__`),
|
|
1098
1095
|
// so composing an already-composed meta a second time duplicates those refs.
|
|
@@ -17,8 +17,7 @@ import {
|
|
|
17
17
|
// request must opt in by setting this flag — and `compose` must propagate
|
|
18
18
|
// it whenever any input has it.
|
|
19
19
|
const isRequestDependent = (fn: unknown): boolean =>
|
|
20
|
-
typeof fn === "function" &&
|
|
21
|
-
(fn as { __requestDependent?: boolean }).__requestDependent === true;
|
|
20
|
+
typeof fn === "function" && (fn as { __requestDependent?: boolean }).__requestDependent === true;
|
|
22
21
|
|
|
23
22
|
const makeReq = (url = "https://store.example/foo?q=hello") =>
|
|
24
23
|
new Request(url, { headers: { "user-agent": "vitest" } });
|
|
@@ -87,12 +86,10 @@ describe("withSectionLoader", () => {
|
|
|
87
86
|
|
|
88
87
|
it("composes alongside mixins: mixins run first, then the section loader sees the enriched props", async () => {
|
|
89
88
|
const seen: Array<Record<string, unknown>> = [];
|
|
90
|
-
const sectionLoader = vi.fn(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
},
|
|
95
|
-
);
|
|
89
|
+
const sectionLoader = vi.fn(async (props: Record<string, unknown>) => {
|
|
90
|
+
seen.push({ ...props });
|
|
91
|
+
return { ...props, sectionLoaderRan: true };
|
|
92
|
+
});
|
|
96
93
|
|
|
97
94
|
const composed = compose(
|
|
98
95
|
withSearchParam(),
|
|
@@ -154,7 +151,14 @@ describe("request-dependent tagging (#206)", () => {
|
|
|
154
151
|
});
|
|
155
152
|
|
|
156
153
|
it("compose does NOT set the flag when no input is request-dependent", () => {
|
|
157
|
-
expect(
|
|
154
|
+
expect(
|
|
155
|
+
isRequestDependent(
|
|
156
|
+
compose(
|
|
157
|
+
async (p) => p,
|
|
158
|
+
async (p) => p,
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
).toBe(false);
|
|
158
162
|
});
|
|
159
163
|
|
|
160
164
|
it("empty compose() is not request-dependent", () => {
|
package/src/cms/sectionMixins.ts
CHANGED
|
@@ -135,9 +135,7 @@ export function compose(...mixins: SectionLoaderFn[]): SectionLoaderFn {
|
|
|
135
135
|
* });
|
|
136
136
|
* ```
|
|
137
137
|
*/
|
|
138
|
-
export function withSectionLoader(
|
|
139
|
-
modImport: () => Promise<unknown>,
|
|
140
|
-
): SectionLoaderFn {
|
|
138
|
+
export function withSectionLoader(modImport: () => Promise<unknown>): SectionLoaderFn {
|
|
141
139
|
return async (props, req, ctx) => {
|
|
142
140
|
const mod = (await modImport()) as { loader?: unknown } | undefined;
|
|
143
141
|
const loader = mod?.loader;
|