@decocms/blocks-admin 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 +2 -2
- package/src/admin/index.ts +1 -1
- package/src/admin/invoke.test.ts +32 -14
- package/src/admin/invoke.ts +35 -16
- package/src/admin/meta.ts +58 -2
- package/src/admin/metaLazy.test.ts +124 -0
- package/src/admin/render.ts +22 -1
- package/src/admin/resolvePreview.test.ts +25 -0
- package/src/admin/resolvePreview.ts +5 -1
- package/src/apps/autoconfig.ts +51 -0
- package/src/createAdminSetup.ts +13 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-admin",
|
|
3
|
-
"version": "8.0.0",
|
|
3
|
+
"version": "8.1.0-next.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco admin protocol: /live/_meta, /.decofile, /deco/invoke, /deco/render, plus admin-facing site setup and commerce-app auto-configuration",
|
|
6
6
|
"repository": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"lint:unused": "knip"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@decocms/blocks": "8.0.0"
|
|
28
|
+
"@decocms/blocks": "8.1.0-next.0"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"react": "^19.0.0",
|
package/src/admin/index.ts
CHANGED
|
@@ -25,7 +25,7 @@ export {
|
|
|
25
25
|
setInvokeLoaders,
|
|
26
26
|
} from "./invoke";
|
|
27
27
|
export { LIVE_CONTROLS_SCRIPT } from "./liveControls";
|
|
28
|
-
export { handleMeta, setMetaData } from "./meta";
|
|
28
|
+
export { handleMeta, setMetaData, setMetaLoader } from "./meta";
|
|
29
29
|
export { handleRender, setPreviewWrapper, setRenderShell } from "./render";
|
|
30
30
|
export {
|
|
31
31
|
type PreviewResolution,
|
package/src/admin/invoke.test.ts
CHANGED
|
@@ -58,7 +58,9 @@ describe("handleInvoke — Set-Cookie propagation (single)", () => {
|
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
const request = makeInvokeRequest("vtex/actions/addItemsToCart");
|
|
61
|
-
const response = await RequestContext.run(request, () =>
|
|
61
|
+
const response = await RequestContext.run(request, () =>
|
|
62
|
+
handleInvoke(request),
|
|
63
|
+
);
|
|
62
64
|
|
|
63
65
|
const cookies = response.headers.getSetCookie();
|
|
64
66
|
expect(cookies).toHaveLength(3);
|
|
@@ -77,7 +79,9 @@ describe("handleInvoke — Set-Cookie propagation (single)", () => {
|
|
|
77
79
|
});
|
|
78
80
|
|
|
79
81
|
const request = makeInvokeRequest("vtex/actions/foo");
|
|
80
|
-
const response = await RequestContext.run(request, () =>
|
|
82
|
+
const response = await RequestContext.run(request, () =>
|
|
83
|
+
handleInvoke(request),
|
|
84
|
+
);
|
|
81
85
|
|
|
82
86
|
// The regressed bug appended a single comma-joined string, so
|
|
83
87
|
// `getSetCookie()` returned a 1-element array. The fix appends each
|
|
@@ -94,7 +98,9 @@ describe("handleInvoke — Set-Cookie propagation (single)", () => {
|
|
|
94
98
|
});
|
|
95
99
|
|
|
96
100
|
const request = makeInvokeRequest("vtex/actions/withHeader");
|
|
97
|
-
const response = await RequestContext.run(request, () =>
|
|
101
|
+
const response = await RequestContext.run(request, () =>
|
|
102
|
+
handleInvoke(request),
|
|
103
|
+
);
|
|
98
104
|
expect(response.headers.get("x-vtex-trace-id")).toBe("abc-123");
|
|
99
105
|
});
|
|
100
106
|
|
|
@@ -104,7 +110,9 @@ describe("handleInvoke — Set-Cookie propagation (single)", () => {
|
|
|
104
110
|
});
|
|
105
111
|
|
|
106
112
|
const request = makeInvokeRequest("vtex/loaders/productList");
|
|
107
|
-
const response = await RequestContext.run(request, () =>
|
|
113
|
+
const response = await RequestContext.run(request, () =>
|
|
114
|
+
handleInvoke(request),
|
|
115
|
+
);
|
|
108
116
|
expect(response.headers.getSetCookie()).toEqual([]);
|
|
109
117
|
});
|
|
110
118
|
});
|
|
@@ -131,7 +139,9 @@ describe("handleInvoke — Set-Cookie propagation (batch)", () => {
|
|
|
131
139
|
"vtex/actions/addItemsToCart": { orderFormId: "x" },
|
|
132
140
|
"vtex/loaders/productList": {},
|
|
133
141
|
});
|
|
134
|
-
const response = await RequestContext.run(request, () =>
|
|
142
|
+
const response = await RequestContext.run(request, () =>
|
|
143
|
+
handleInvoke(request),
|
|
144
|
+
);
|
|
135
145
|
|
|
136
146
|
const cookies = response.headers.getSetCookie();
|
|
137
147
|
expect(cookies).toHaveLength(3);
|
|
@@ -155,7 +165,8 @@ describe("handleInvoke — draft binding", () => {
|
|
|
155
165
|
clearDraftCache();
|
|
156
166
|
setBlocks({ "site/x": { value: "published" } });
|
|
157
167
|
registerInvokeHandlers({
|
|
158
|
-
"site/loaders/x.ts": async () =>
|
|
168
|
+
"site/loaders/x.ts": async () =>
|
|
169
|
+
(loadBlocks() as Record<string, any>)["site/x"],
|
|
159
170
|
});
|
|
160
171
|
});
|
|
161
172
|
afterEach(() => {
|
|
@@ -166,11 +177,14 @@ describe("handleInvoke — draft binding", () => {
|
|
|
166
177
|
});
|
|
167
178
|
|
|
168
179
|
function invokeReq(headers: Record<string, string>): Request {
|
|
169
|
-
return new Request(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
180
|
+
return new Request(
|
|
181
|
+
"https://preview.example/deco/invoke/site/loaders/x.ts",
|
|
182
|
+
{
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: { "content-type": "application/json", ...headers },
|
|
185
|
+
body: "{}",
|
|
186
|
+
},
|
|
187
|
+
);
|
|
174
188
|
}
|
|
175
189
|
|
|
176
190
|
it("resolves loaders against the DRAFT when the request carries an allowed draft cookie", async () => {
|
|
@@ -183,7 +197,8 @@ describe("handleInvoke — draft binding", () => {
|
|
|
183
197
|
const res = await handleInvoke(
|
|
184
198
|
invokeReq({
|
|
185
199
|
"x-forwarded-host": "preview.example",
|
|
186
|
-
cookie:
|
|
200
|
+
cookie:
|
|
201
|
+
"__deco_draft=studio.decocms.com/api/o/decofile/m/main%3Ftoken%3Dt@vX",
|
|
187
202
|
}),
|
|
188
203
|
);
|
|
189
204
|
expect(await res.json()).toEqual({ value: "draft" });
|
|
@@ -197,7 +212,9 @@ describe("handleInvoke — draft binding", () => {
|
|
|
197
212
|
return new Response("{}");
|
|
198
213
|
}) as unknown as typeof fetch;
|
|
199
214
|
|
|
200
|
-
const res = await handleInvoke(
|
|
215
|
+
const res = await handleInvoke(
|
|
216
|
+
invokeReq({ "x-forwarded-host": "preview.example" }),
|
|
217
|
+
);
|
|
201
218
|
expect(await res.json()).toEqual({ value: "published" });
|
|
202
219
|
expect(called).toBe(false);
|
|
203
220
|
});
|
|
@@ -212,7 +229,8 @@ describe("handleInvoke — draft binding", () => {
|
|
|
212
229
|
const res = await handleInvoke(
|
|
213
230
|
invokeReq({
|
|
214
231
|
"x-forwarded-host": "prod.example",
|
|
215
|
-
cookie:
|
|
232
|
+
cookie:
|
|
233
|
+
"__deco_draft=studio.decocms.com/api/o/decofile/m/main%3Ftoken%3Dt@vX",
|
|
216
234
|
}),
|
|
217
235
|
);
|
|
218
236
|
expect(await res.json()).toEqual({ value: "published" });
|
package/src/admin/invoke.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* copies those headers into the final HTTP Response.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { resolveDraftForRequest,
|
|
16
|
+
import { resolveDraftForRequest, withDraftBlocks } from "@decocms/blocks/cms";
|
|
17
17
|
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
18
18
|
|
|
19
19
|
export type InvokeLoader = (props: any, request: Request) => Promise<any>;
|
|
@@ -95,11 +95,13 @@ function forwardCtxHeadersTo(response: Response): void {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
const isDev =
|
|
98
|
-
typeof globalThis.process !== "undefined" &&
|
|
98
|
+
typeof globalThis.process !== "undefined" &&
|
|
99
|
+
globalThis.process.env?.NODE_ENV === "development";
|
|
99
100
|
|
|
100
101
|
function selectFields(data: unknown, select?: string[]): unknown {
|
|
101
102
|
if (!select?.length || !data || typeof data !== "object") return data;
|
|
102
|
-
if (Array.isArray(data))
|
|
103
|
+
if (Array.isArray(data))
|
|
104
|
+
return data.map((item) => selectFields(item, select));
|
|
103
105
|
const result: Record<string, unknown> = {};
|
|
104
106
|
for (const key of select) {
|
|
105
107
|
if (key in (data as Record<string, unknown>)) {
|
|
@@ -131,7 +133,9 @@ async function parseBody(request: Request): Promise<any> {
|
|
|
131
133
|
for (const [key, value] of formData.entries()) {
|
|
132
134
|
if (obj[key] !== undefined) {
|
|
133
135
|
// Multiple values → array
|
|
134
|
-
const existing = Array.isArray(obj[key])
|
|
136
|
+
const existing = Array.isArray(obj[key])
|
|
137
|
+
? (obj[key] as unknown[])
|
|
138
|
+
: [obj[key]];
|
|
135
139
|
existing.push(value);
|
|
136
140
|
obj[key] = existing;
|
|
137
141
|
} else {
|
|
@@ -193,10 +197,9 @@ function unknownHandlerMessage(key: string): string {
|
|
|
193
197
|
const kind = isAction ? "action" : isLoader ? "loader" : "loader/action";
|
|
194
198
|
const dir = isAction ? "src/actions/" : "src/loaders/";
|
|
195
199
|
const cliHint = "npm run generate:loaders";
|
|
196
|
-
const setupHint =
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
: "setInvokeLoaders(() => COMMERCE_LOADERS)";
|
|
200
|
+
const setupHint = isAction
|
|
201
|
+
? "setInvokeActions(() => COMMERCE_ACTIONS)"
|
|
202
|
+
: "setInvokeLoaders(() => COMMERCE_LOADERS)";
|
|
200
203
|
return (
|
|
201
204
|
`Unknown handler: ${key}. ` +
|
|
202
205
|
`Check that the ${kind} exists in ${dir} (file path must match the key — ` +
|
|
@@ -221,16 +224,17 @@ function warnMissingHandlerOnce(key: string): void {
|
|
|
221
224
|
* refs; without this, `/deco/invoke` — a separate request from the page render,
|
|
222
225
|
* so the page's request-scoped draft binding never reaches it — would resolve
|
|
223
226
|
* against PUBLISHED blocks and hand a reviewer stale content in any
|
|
224
|
-
* client-fetched (lazy/on-scroll) or self-fetched section. `
|
|
227
|
+
* client-fetched (lazy/on-scroll) or self-fetched section. `withDraftBlocks`
|
|
225
228
|
* uses AsyncLocalStorage, so each concurrent invoke gets its own override with
|
|
226
229
|
* no cross-request bleed (the same isolation the layout-cache race taught us to
|
|
227
|
-
* keep)
|
|
228
|
-
*
|
|
230
|
+
* keep) — and composes the draft as a SNAPSHOT, the same semantics the page
|
|
231
|
+
* render applies, so a block deleted in the draft is deleted here too. Inert
|
|
232
|
+
* unless the draft gate passes — no draft, no wrap, published path untouched.
|
|
229
233
|
*/
|
|
230
234
|
export async function handleInvoke(request: Request): Promise<Response> {
|
|
231
235
|
const draftBlocks = await resolveDraftForRequest(request);
|
|
232
236
|
return draftBlocks
|
|
233
|
-
?
|
|
237
|
+
? withDraftBlocks(draftBlocks, () => dispatchInvoke(request))
|
|
234
238
|
: dispatchInvoke(request);
|
|
235
239
|
}
|
|
236
240
|
|
|
@@ -258,7 +262,10 @@ async function dispatchInvoke(request: Request): Promise<Response> {
|
|
|
258
262
|
return result;
|
|
259
263
|
}
|
|
260
264
|
const filtered = selectFields(result, select);
|
|
261
|
-
const response = new Response(JSON.stringify(filtered), {
|
|
265
|
+
const response = new Response(JSON.stringify(filtered), {
|
|
266
|
+
status: 200,
|
|
267
|
+
headers: JSON_HEADERS,
|
|
268
|
+
});
|
|
262
269
|
forwardCtxHeadersTo(response);
|
|
263
270
|
return response;
|
|
264
271
|
} catch (error) {
|
|
@@ -267,7 +274,12 @@ async function dispatchInvoke(request: Request): Promise<Response> {
|
|
|
267
274
|
}
|
|
268
275
|
|
|
269
276
|
// Batch invoke
|
|
270
|
-
if (
|
|
277
|
+
if (
|
|
278
|
+
request.method === "POST" &&
|
|
279
|
+
body &&
|
|
280
|
+
typeof body === "object" &&
|
|
281
|
+
!Array.isArray(body)
|
|
282
|
+
) {
|
|
271
283
|
const results: Record<string, unknown> = {};
|
|
272
284
|
|
|
273
285
|
const entries = Object.entries(body as Record<string, unknown>);
|
|
@@ -286,7 +298,11 @@ async function dispatchInvoke(request: Request): Promise<Response> {
|
|
|
286
298
|
// RequestContext.responseHeaders (which forwardCtxHeadersTo()
|
|
287
299
|
// below propagates onto the batch response).
|
|
288
300
|
if (result instanceof Response) {
|
|
289
|
-
try {
|
|
301
|
+
try {
|
|
302
|
+
result = await result.json();
|
|
303
|
+
} catch {
|
|
304
|
+
result = null;
|
|
305
|
+
}
|
|
290
306
|
}
|
|
291
307
|
results[key] = selectFields(result, select);
|
|
292
308
|
} catch (error) {
|
|
@@ -299,7 +315,10 @@ async function dispatchInvoke(request: Request): Promise<Response> {
|
|
|
299
315
|
}),
|
|
300
316
|
);
|
|
301
317
|
|
|
302
|
-
const response = new Response(JSON.stringify(results), {
|
|
318
|
+
const response = new Response(JSON.stringify(results), {
|
|
319
|
+
status: 200,
|
|
320
|
+
headers: JSON_HEADERS,
|
|
321
|
+
});
|
|
303
322
|
// All batch handlers share the same RequestContext, so any Set-Cookie
|
|
304
323
|
// they appended (e.g. from VTEX vtexFetchWithCookies) is in
|
|
305
324
|
// `responseHeaders` by now. Forward it as N distinct Set-Cookie headers.
|
package/src/admin/meta.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { composeMeta, type MetaResponse } from "@decocms/blocks/cms";
|
|
|
8
8
|
const G = globalThis as unknown as {
|
|
9
9
|
__deco_meta_data?: MetaResponse | null;
|
|
10
10
|
__deco_meta_etag?: string | null;
|
|
11
|
+
__deco_meta_loader?: (() => Promise<MetaResponse>) | null;
|
|
12
|
+
__deco_meta_loading?: Promise<MetaResponse | null> | null;
|
|
11
13
|
};
|
|
12
14
|
|
|
13
15
|
function getMetaData(): MetaResponse | null {
|
|
@@ -35,6 +37,60 @@ function setCachedEtag(etag: string | null) {
|
|
|
35
37
|
*/
|
|
36
38
|
export function invalidateMetaCache() {
|
|
37
39
|
setCachedEtag(null);
|
|
40
|
+
// Drop the composed schema too, but ONLY when it can be rebuilt. With a
|
|
41
|
+
// loader registered the next request re-imports and re-composes (composeMeta
|
|
42
|
+
// injects page schemas over the current blocks, so a decofile change really
|
|
43
|
+
// does invalidate it). With data set explicitly via `setMetaData` there is
|
|
44
|
+
// nothing to reload — clearing it would 503 the admin permanently.
|
|
45
|
+
if (G.__deco_meta_loader) setMetaDataInternal(null);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Register the schema loader instead of resolving it now.
|
|
50
|
+
*
|
|
51
|
+
* The schema is a JSON Schema bundle covering every section and app, and it is
|
|
52
|
+
* needed by exactly one route: `/live/_meta`. Loading it at boot meant every
|
|
53
|
+
* isolate parsed it and pinned the composed graph on `globalThis` for its whole
|
|
54
|
+
* life — for traffic that never touches the admin. Same shape as the decofile
|
|
55
|
+
* bug: a large JSON graph permanently reachable from a module-level binding.
|
|
56
|
+
*
|
|
57
|
+
* `createAdminSetup` has always documented this as lazy; it just wasn't.
|
|
58
|
+
*/
|
|
59
|
+
export function setMetaLoader(loader: () => Promise<MetaResponse>) {
|
|
60
|
+
G.__deco_meta_loader = loader;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The composed schema, importing + composing it on first use.
|
|
65
|
+
*
|
|
66
|
+
* Concurrent first requests share one in-flight load. A failed load is NOT
|
|
67
|
+
* latched: the rejection is swallowed to `null` (the caller answers 503) and
|
|
68
|
+
* the next request retries, so a transient import failure can't disable the
|
|
69
|
+
* admin for the isolate's life.
|
|
70
|
+
*/
|
|
71
|
+
async function ensureMetaData(): Promise<MetaResponse | null> {
|
|
72
|
+
const existing = getMetaData();
|
|
73
|
+
if (existing) return existing;
|
|
74
|
+
|
|
75
|
+
const loader = G.__deco_meta_loader;
|
|
76
|
+
if (!loader) return null;
|
|
77
|
+
|
|
78
|
+
G.__deco_meta_loading ??= loader()
|
|
79
|
+
.then((data) => {
|
|
80
|
+
const composed = composeMeta(data);
|
|
81
|
+
setMetaDataInternal(composed);
|
|
82
|
+
setCachedEtag(null);
|
|
83
|
+
return composed;
|
|
84
|
+
})
|
|
85
|
+
.catch((err) => {
|
|
86
|
+
console.warn("[deco] admin meta schema failed to load:", err);
|
|
87
|
+
return null;
|
|
88
|
+
})
|
|
89
|
+
.finally(() => {
|
|
90
|
+
G.__deco_meta_loading = null;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return G.__deco_meta_loading;
|
|
38
94
|
}
|
|
39
95
|
|
|
40
96
|
/**
|
|
@@ -62,8 +118,8 @@ function getEtag(): string {
|
|
|
62
118
|
return etag;
|
|
63
119
|
}
|
|
64
120
|
|
|
65
|
-
export function handleMeta(request: Request): Response {
|
|
66
|
-
const metaData =
|
|
121
|
+
export async function handleMeta(request: Request): Promise<Response> {
|
|
122
|
+
const metaData = await ensureMetaData();
|
|
67
123
|
if (!metaData) {
|
|
68
124
|
return new Response(JSON.stringify({ error: "Schema not initialized" }), {
|
|
69
125
|
status: 503,
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { MetaResponse } from "@decocms/blocks/cms";
|
|
3
|
+
import { createAdminSetup } from "../createAdminSetup";
|
|
4
|
+
import { handleMeta, invalidateMetaCache, setMetaData, setMetaLoader } from "./meta";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The admin JSON Schema bundle covers every section and app, and exactly one
|
|
8
|
+
* route needs it. Loading it at boot made every isolate parse it and pin the
|
|
9
|
+
* composed graph on globalThis for its whole life — for traffic that never
|
|
10
|
+
* touches the admin. Same shape as the decofile bug.
|
|
11
|
+
*
|
|
12
|
+
* These tests pin the property that matters: nothing loads the schema until a
|
|
13
|
+
* request asks for it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const G = globalThis as unknown as Record<string, unknown>;
|
|
17
|
+
|
|
18
|
+
function reset() {
|
|
19
|
+
G.__deco_meta_data = null;
|
|
20
|
+
G.__deco_meta_etag = null;
|
|
21
|
+
G.__deco_meta_loader = null;
|
|
22
|
+
G.__deco_meta_loading = null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SCHEMA = {
|
|
26
|
+
definitions: { "site/sections/Hero.tsx": { type: "object" } },
|
|
27
|
+
} as unknown as MetaResponse;
|
|
28
|
+
const req = () => new Request("https://site.test/live/_meta");
|
|
29
|
+
|
|
30
|
+
beforeEach(reset);
|
|
31
|
+
|
|
32
|
+
describe("lazy admin meta schema", () => {
|
|
33
|
+
it("does NOT invoke the loader at setup time", () => {
|
|
34
|
+
const meta = vi.fn(() => Promise.resolve(SCHEMA));
|
|
35
|
+
createAdminSetup({ meta, css: "/app.css" });
|
|
36
|
+
// The whole point: boot must not touch the schema.
|
|
37
|
+
expect(meta).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("invokes it on the first /live/_meta, once", async () => {
|
|
41
|
+
const meta = vi.fn(() => Promise.resolve(SCHEMA));
|
|
42
|
+
createAdminSetup({ meta, css: "/app.css" });
|
|
43
|
+
|
|
44
|
+
const first = await handleMeta(req());
|
|
45
|
+
expect(first.status).toBe(200);
|
|
46
|
+
expect(meta).toHaveBeenCalledTimes(1);
|
|
47
|
+
|
|
48
|
+
await handleMeta(req());
|
|
49
|
+
expect(meta).toHaveBeenCalledTimes(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("shares one in-flight load across concurrent first requests", async () => {
|
|
53
|
+
let release!: (v: unknown) => void;
|
|
54
|
+
const meta = vi.fn(
|
|
55
|
+
() =>
|
|
56
|
+
new Promise<never>((r) => {
|
|
57
|
+
release = r as never;
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
setMetaLoader(meta as unknown as () => Promise<typeof SCHEMA>);
|
|
61
|
+
|
|
62
|
+
const all = Promise.all([handleMeta(req()), handleMeta(req()), handleMeta(req())]);
|
|
63
|
+
release(SCHEMA);
|
|
64
|
+
const responses = await all;
|
|
65
|
+
|
|
66
|
+
expect(meta).toHaveBeenCalledTimes(1);
|
|
67
|
+
for (const r of responses) expect(r.status).toBe(200);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("serves the COMPOSED schema, not the raw input", async () => {
|
|
71
|
+
setMetaLoader(() => Promise.resolve(SCHEMA));
|
|
72
|
+
const body = (await (await handleMeta(req())).json()) as { framework?: unknown };
|
|
73
|
+
// `framework` is composeMeta's own sentinel (it is the field it sets and
|
|
74
|
+
// then uses as its idempotency guard), so its presence proves the lazy
|
|
75
|
+
// path still composes rather than serving the file through.
|
|
76
|
+
expect(body.framework).toBeDefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("retries after a failed load instead of latching a dead isolate", async () => {
|
|
80
|
+
const meta = vi
|
|
81
|
+
.fn<() => Promise<typeof SCHEMA>>()
|
|
82
|
+
.mockRejectedValueOnce(new Error("chunk load failed"))
|
|
83
|
+
.mockResolvedValueOnce(SCHEMA);
|
|
84
|
+
setMetaLoader(meta);
|
|
85
|
+
|
|
86
|
+
expect((await handleMeta(req())).status).toBe(503);
|
|
87
|
+
expect((await handleMeta(req())).status).toBe(200);
|
|
88
|
+
expect(meta).toHaveBeenCalledTimes(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("503s when no loader and no data were ever provided", async () => {
|
|
92
|
+
expect((await handleMeta(req())).status).toBe(503);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("still honors an explicitly set schema, with no loader", async () => {
|
|
96
|
+
setMetaData(SCHEMA);
|
|
97
|
+
expect((await handleMeta(req())).status).toBe(200);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("keeps explicitly-set data across invalidation — there is nothing to reload", async () => {
|
|
101
|
+
// Clearing it would 503 the admin permanently for a site on setMetaData.
|
|
102
|
+
setMetaData(SCHEMA);
|
|
103
|
+
invalidateMetaCache();
|
|
104
|
+
expect((await handleMeta(req())).status).toBe(200);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("re-composes after invalidation when a loader can rebuild it", async () => {
|
|
108
|
+
const meta = vi.fn(() => Promise.resolve(SCHEMA));
|
|
109
|
+
setMetaLoader(meta);
|
|
110
|
+
await handleMeta(req());
|
|
111
|
+
invalidateMetaCache();
|
|
112
|
+
await handleMeta(req());
|
|
113
|
+
expect(meta).toHaveBeenCalledTimes(2);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("answers 304 to a matching If-None-Match", async () => {
|
|
117
|
+
setMetaLoader(() => Promise.resolve(SCHEMA));
|
|
118
|
+
const etag = (await handleMeta(req())).headers.get("etag")!;
|
|
119
|
+
const res = await handleMeta(
|
|
120
|
+
new Request("https://site.test/live/_meta", { headers: { "if-none-match": etag } }),
|
|
121
|
+
);
|
|
122
|
+
expect(res.status).toBe(304);
|
|
123
|
+
});
|
|
124
|
+
});
|
package/src/admin/render.ts
CHANGED
|
@@ -31,6 +31,18 @@ function wrapInHtmlShell(sectionHtml: string): string {
|
|
|
31
31
|
return buildHtmlShell({ body: sectionHtml, script: LIVE_CONTROLS_SCRIPT });
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* DOM id for a section's wrapper element. Must stay in sync with the
|
|
36
|
+
* production renderer (`DecoPageRenderer.tsx`) so the admin's in-place
|
|
37
|
+
* Fast Preview replace can match `[data-manifest-key]` / `#id` 1:1.
|
|
38
|
+
*/
|
|
39
|
+
function sectionDomId(key: string): string {
|
|
40
|
+
return key
|
|
41
|
+
.replace(/\//g, "-")
|
|
42
|
+
.replace(/\.tsx$/, "")
|
|
43
|
+
.replace(/^site-sections-/, "");
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
/**
|
|
35
47
|
* Render a single ResolvedSection to an HTML string.
|
|
36
48
|
* Uses the pre-cached renderToString and the preview wrapper.
|
|
@@ -44,8 +56,17 @@ async function renderResolvedSection(section: ResolvedSection): Promise<string>
|
|
|
44
56
|
const renderToString = await getRenderToString();
|
|
45
57
|
const mod = await sectionLoader();
|
|
46
58
|
const element = createElement(mod.default, section.props);
|
|
59
|
+
// Match the production wrapper (DecoPageRenderer.tsx) so the admin's
|
|
60
|
+
// in-place Fast Preview replace can map each section to its live-DOM
|
|
61
|
+
// counterpart via [data-manifest-key]. Without it, sections rendered on
|
|
62
|
+
// the /live/previews path come back unwrapped and end up glued together.
|
|
63
|
+
const sectionEl = createElement(
|
|
64
|
+
"section",
|
|
65
|
+
{ id: sectionDomId(section.key), "data-manifest-key": section.key },
|
|
66
|
+
element,
|
|
67
|
+
);
|
|
47
68
|
const Wrapper = getPreviewWrapper();
|
|
48
|
-
const wrapped = Wrapper ? createElement(Wrapper, null,
|
|
69
|
+
const wrapped = Wrapper ? createElement(Wrapper, null, sectionEl) : sectionEl;
|
|
49
70
|
return renderToString(wrapped);
|
|
50
71
|
}
|
|
51
72
|
|
|
@@ -126,4 +126,29 @@ describe("resolvePreviewRequest", () => {
|
|
|
126
126
|
expect(html).toContain("hero-html");
|
|
127
127
|
expect(html).toContain("editor::inject");
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
it("wraps each rendered section in <section data-manifest-key> so the in-place preview replace can map it", async () => {
|
|
131
|
+
setBlocks({
|
|
132
|
+
"/pages/home": {
|
|
133
|
+
__resolveType: WELL_KNOWN_TYPES.PAGE,
|
|
134
|
+
sections: [
|
|
135
|
+
{ __resolveType: HERO, label: "a" },
|
|
136
|
+
{ __resolveType: HERO, label: "b" },
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const response = await handleRender(
|
|
142
|
+
new Request(`http://localhost/live/previews/${encodeURIComponent("/pages/home")}`),
|
|
143
|
+
);
|
|
144
|
+
const html = await response.text();
|
|
145
|
+
|
|
146
|
+
// Same wrapper markup as production (DecoPageRenderer.tsx): id derived from
|
|
147
|
+
// the section key, data-manifest-key set to the raw key.
|
|
148
|
+
expect(html).toContain(`<section id="Hero" data-manifest-key="${HERO}">`);
|
|
149
|
+
// Both sections are individually wrapped — not concatenated into one blob.
|
|
150
|
+
expect(html.match(/data-manifest-key/g)?.length).toBe(2);
|
|
151
|
+
expect(html).toContain("hero-a");
|
|
152
|
+
expect(html).toContain("hero-b");
|
|
153
|
+
});
|
|
129
154
|
});
|
|
@@ -162,7 +162,11 @@ export async function resolvePreviewRequest(request: Request): Promise<PreviewRe
|
|
|
162
162
|
return { type: "unknown", component };
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
const resolvedProps = (await resolveValue(
|
|
165
|
+
const resolvedProps = (await resolveValue(
|
|
166
|
+
props,
|
|
167
|
+
undefined,
|
|
168
|
+
buildPreviewMatcherCtx(request),
|
|
169
|
+
)) as Record<string, unknown>;
|
|
166
170
|
const { __resolveType: _, ...cleanProps } = resolvedProps;
|
|
167
171
|
return {
|
|
168
172
|
type: "sections",
|
package/src/apps/autoconfig.ts
CHANGED
|
@@ -70,6 +70,30 @@ export interface AppRegistryEntry {
|
|
|
70
70
|
|
|
71
71
|
export type AppRegistry = readonly AppRegistryEntry[];
|
|
72
72
|
|
|
73
|
+
// One-shot re-configuration registered by autoconfigApps() and triggered by the
|
|
74
|
+
// first request (reconfigureAppsOnce). See reconfigureAppsOnce() below for why.
|
|
75
|
+
let pendingReinit: (() => Promise<void>) | null = null;
|
|
76
|
+
let reinitPromise: Promise<void> | null = null;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Re-run app configuration once, the first time it's called from within a
|
|
80
|
+
* request scope. Idempotent — subsequent calls await the same in-flight promise
|
|
81
|
+
* (or return immediately once done).
|
|
82
|
+
*
|
|
83
|
+
* Why this exists: autoconfigApps() runs at module-init (setup.ts), BEFORE any
|
|
84
|
+
* request. In CF Workers production/preview the `env` binding (holding
|
|
85
|
+
* DECO_CRYPTO_KEY) is only available inside the fetch handler, so the startup
|
|
86
|
+
* pass decrypts nothing and configures apps with null credentials. workerEntry
|
|
87
|
+
* calls this after setRuntimeEnv(env) so configure() re-runs with the real key
|
|
88
|
+
* now reachable via getRuntimeEnv() inside crypto.ts's getEnvVar().
|
|
89
|
+
*/
|
|
90
|
+
export function reconfigureAppsOnce(): Promise<void> {
|
|
91
|
+
if (reinitPromise) return reinitPromise;
|
|
92
|
+
if (!pendingReinit) return Promise.resolve(); // nothing registered yet
|
|
93
|
+
reinitPromise = pendingReinit();
|
|
94
|
+
return reinitPromise;
|
|
95
|
+
}
|
|
96
|
+
|
|
73
97
|
async function configureAllApps(
|
|
74
98
|
blocks: Record<string, unknown>,
|
|
75
99
|
registry: AppRegistry,
|
|
@@ -137,12 +161,39 @@ export async function autoconfigApps(
|
|
|
137
161
|
await setupApps(apps);
|
|
138
162
|
}
|
|
139
163
|
|
|
164
|
+
// Latest blocks, kept current by onChange so the first-request re-init below
|
|
165
|
+
// uses fresh content if the decofile was reloaded before the first request.
|
|
166
|
+
let latestBlocks = blocks;
|
|
167
|
+
|
|
140
168
|
// Re-configure on admin hot-reload
|
|
141
169
|
onChange(async (newBlocks) => {
|
|
142
170
|
if (typeof document !== "undefined") return;
|
|
171
|
+
latestBlocks = newBlocks;
|
|
143
172
|
const updatedApps = await configureAllApps(newBlocks, registry);
|
|
144
173
|
if (updatedApps.length > 0) {
|
|
145
174
|
await setupApps(updatedApps);
|
|
146
175
|
}
|
|
147
176
|
});
|
|
177
|
+
|
|
178
|
+
// Register the one-shot re-init fired on the first request (where the CF
|
|
179
|
+
// Workers `env` — and thus DECO_CRYPTO_KEY — becomes reachable). See
|
|
180
|
+
// reconfigureAppsOnce() for the full rationale.
|
|
181
|
+
pendingReinit = async () => {
|
|
182
|
+
const reconfigured = await configureAllApps(latestBlocks, registry);
|
|
183
|
+
if (reconfigured.length > 0) {
|
|
184
|
+
await setupApps(reconfigured);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Test-only: clear the one-shot re-init state.
|
|
191
|
+
*
|
|
192
|
+
* `pendingReinit` / `reinitPromise` are module-level and latching by design
|
|
193
|
+
* (see `reconfigureAppsOnce`), so without this a test that registers apps
|
|
194
|
+
* leaks that registration into every later test in the same module graph.
|
|
195
|
+
*/
|
|
196
|
+
export function __resetAutoconfigStateForTests(): void {
|
|
197
|
+
pendingReinit = null;
|
|
198
|
+
reinitPromise = null;
|
|
148
199
|
}
|
package/src/createAdminSetup.ts
CHANGED
|
@@ -6,17 +6,17 @@
|
|
|
6
6
|
* here because these four steps need admin/index.ts's setters, which
|
|
7
7
|
* runtime cannot import without creating a circular dependency.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
10
|
-
setInvokeLoaders,
|
|
11
|
-
setMetaData,
|
|
12
|
-
setPreviewWrapper,
|
|
13
|
-
setRenderShell,
|
|
14
|
-
} from "./admin/index";
|
|
9
|
+
import { setInvokeLoaders, setMetaLoader, setPreviewWrapper, setRenderShell } from "./admin/index";
|
|
15
10
|
|
|
16
11
|
export interface AdminSetupOptions {
|
|
17
12
|
/**
|
|
18
|
-
* Lazy loader for admin meta schema — only
|
|
13
|
+
* Lazy loader for admin meta schema — only invoked on the first `/live/_meta`
|
|
14
|
+
* request, never at boot:
|
|
19
15
|
* `() => import("./server/admin/meta.gen.json").then(m => m.default)`
|
|
16
|
+
*
|
|
17
|
+
* Keep it a dynamic `import()`. A static import would defeat the laziness:
|
|
18
|
+
* the module graph would pull the schema in at boot no matter when this thunk
|
|
19
|
+
* runs.
|
|
20
20
|
*/
|
|
21
21
|
meta: () => Promise<any>;
|
|
22
22
|
|
|
@@ -43,8 +43,12 @@ export interface AdminSetupOptions {
|
|
|
43
43
|
* createSiteSetup() (@decocms/blocks/setup).
|
|
44
44
|
*/
|
|
45
45
|
export function createAdminSetup(options: AdminSetupOptions): void {
|
|
46
|
-
// 7. Admin meta schema
|
|
47
|
-
|
|
46
|
+
// 7. Admin meta schema — REGISTERED, not loaded. The schema is a JSON Schema
|
|
47
|
+
// bundle covering every section and app, and exactly one route needs it
|
|
48
|
+
// (`/live/_meta`). Invoking the thunk here made every isolate parse it at boot
|
|
49
|
+
// and pin the composed graph for its whole life, for traffic that never
|
|
50
|
+
// touches the admin.
|
|
51
|
+
setMetaLoader(options.meta);
|
|
48
52
|
|
|
49
53
|
// 8. Render shell
|
|
50
54
|
setRenderShell({
|