@decocms/blocks-admin 7.0.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 +39 -0
- package/src/admin/cors.ts +49 -0
- package/src/admin/decofile.test.ts +146 -0
- package/src/admin/decofile.ts +189 -0
- package/src/admin/index.ts +29 -0
- package/src/admin/invoke.test.ts +141 -0
- package/src/admin/invoke.ts +290 -0
- package/src/admin/liveControls.ts +29 -0
- package/src/admin/meta.ts +91 -0
- package/src/admin/render.ts +289 -0
- package/src/admin/setup.ts +85 -0
- package/src/apps/autoconfig.ts +135 -0
- package/src/apps/index.ts +24 -0
- package/src/createAdminSetup.ts +64 -0
- package/src/sdk/htmlShell.ts +55 -0
- package/src/sdk/setupApps.ts +263 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handles /deco/invoke -- executes loaders and actions by key.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - Single invoke by key: POST /deco/invoke/some/loader.ts
|
|
6
|
+
* - Batch invoke: POST /deco/invoke with { key: payload } body
|
|
7
|
+
* - FormData parsing for file uploads and form submissions
|
|
8
|
+
* - `?select=field1,field2` to pick fields from the result
|
|
9
|
+
* - Resolves __resolveType in batch payloads
|
|
10
|
+
*
|
|
11
|
+
* Handlers can write to `RequestContext.responseHeaders` to forward
|
|
12
|
+
* headers (e.g., Set-Cookie from VTEX checkout). The invoke endpoint
|
|
13
|
+
* copies those headers into the final HTTP Response.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
17
|
+
|
|
18
|
+
export type InvokeLoader = (props: any, request: Request) => Promise<any>;
|
|
19
|
+
export type InvokeAction = (props: any, request: Request) => Promise<any>;
|
|
20
|
+
|
|
21
|
+
// Additive handler registry — registerInvokeHandlers() adds to this map.
|
|
22
|
+
const handlerRegistry = new Map<string, InvokeLoader | InvokeAction>();
|
|
23
|
+
|
|
24
|
+
// Legacy getter-based registries (backward compat for setInvokeLoaders/Actions).
|
|
25
|
+
let getRegisteredLoaders: () => Record<string, InvokeLoader> = () => ({});
|
|
26
|
+
let getRegisteredActions: () => Record<string, InvokeAction> = () => ({});
|
|
27
|
+
|
|
28
|
+
export function setInvokeLoaders(getter: () => Record<string, InvokeLoader>) {
|
|
29
|
+
getRegisteredLoaders = getter;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function setInvokeActions(getter: () => Record<string, InvokeAction>) {
|
|
33
|
+
getRegisteredActions = getter;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Additive handler registration — adds handlers without replacing existing ones.
|
|
38
|
+
* First registration wins: if a key already exists, it is NOT overwritten.
|
|
39
|
+
* Use this for automatic manifest-based registration from setupApps().
|
|
40
|
+
*/
|
|
41
|
+
export function registerInvokeHandlers(
|
|
42
|
+
handlers: Record<string, InvokeLoader | InvokeAction>,
|
|
43
|
+
): void {
|
|
44
|
+
for (const [key, handler] of Object.entries(handlers)) {
|
|
45
|
+
if (!handlerRegistry.has(key)) {
|
|
46
|
+
handlerRegistry.set(key, handler);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Clear all registered invoke handlers.
|
|
53
|
+
* Called by setupApps() before re-registering on hot-reload.
|
|
54
|
+
*/
|
|
55
|
+
export function clearInvokeHandlers(): void {
|
|
56
|
+
handlerRegistry.clear();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const JSON_HEADERS = { "Content-Type": "application/json" } as const;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Copy headers that handlers wrote to `RequestContext.responseHeaders`
|
|
63
|
+
* onto an outgoing Response.
|
|
64
|
+
*
|
|
65
|
+
* Why this exists and is not a `for…of headers.entries()` one-liner:
|
|
66
|
+
* `Headers.entries()` (and `forEach`) collapses multiple `Set-Cookie`
|
|
67
|
+
* values into a single comma-joined string (per the Fetch spec).
|
|
68
|
+
* Browsers silently discard cookies whose value contains an unescaped
|
|
69
|
+
* comma, so every VTEX cart action that returns multiple cookies
|
|
70
|
+
* (`checkout.vtex.com__orderFormId`, `segment`, `sc`, `vtex_session`…)
|
|
71
|
+
* loses them in transit. The next request creates a fresh empty
|
|
72
|
+
* orderForm and the user lands on /checkout with an empty cart.
|
|
73
|
+
*
|
|
74
|
+
* `Headers.getSetCookie()` is the spec-blessed way to read the
|
|
75
|
+
* un-collapsed list. We append each value individually onto the
|
|
76
|
+
* response so the browser sees N distinct `Set-Cookie` headers, and
|
|
77
|
+
* use `forEach` to copy any non-cookie headers as-is.
|
|
78
|
+
*/
|
|
79
|
+
function forwardCtxHeadersTo(response: Response): void {
|
|
80
|
+
const ctx = RequestContext.current;
|
|
81
|
+
if (!ctx) return;
|
|
82
|
+
const cookies =
|
|
83
|
+
typeof ctx.responseHeaders.getSetCookie === "function"
|
|
84
|
+
? ctx.responseHeaders.getSetCookie()
|
|
85
|
+
: [];
|
|
86
|
+
for (const cookie of cookies) {
|
|
87
|
+
response.headers.append("set-cookie", cookie);
|
|
88
|
+
}
|
|
89
|
+
ctx.responseHeaders.forEach((value, key) => {
|
|
90
|
+
if (key.toLowerCase() !== "set-cookie") {
|
|
91
|
+
response.headers.append(key, value);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const isDev =
|
|
97
|
+
typeof globalThis.process !== "undefined" && globalThis.process.env?.NODE_ENV === "development";
|
|
98
|
+
|
|
99
|
+
function selectFields(data: unknown, select?: string[]): unknown {
|
|
100
|
+
if (!select?.length || !data || typeof data !== "object") return data;
|
|
101
|
+
if (Array.isArray(data)) return data.map((item) => selectFields(item, select));
|
|
102
|
+
const result: Record<string, unknown> = {};
|
|
103
|
+
for (const key of select) {
|
|
104
|
+
if (key in (data as Record<string, unknown>)) {
|
|
105
|
+
result[key] = (data as Record<string, unknown>)[key];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function errorResponse(message: string, status: number, error?: unknown) {
|
|
112
|
+
const body: Record<string, unknown> = { error: message };
|
|
113
|
+
if (isDev && error instanceof Error && error.stack) {
|
|
114
|
+
body.stack = error.stack;
|
|
115
|
+
}
|
|
116
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function parseBody(request: Request): Promise<any> {
|
|
120
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
121
|
+
|
|
122
|
+
// FormData
|
|
123
|
+
if (
|
|
124
|
+
contentType.includes("multipart/form-data") ||
|
|
125
|
+
contentType.includes("application/x-www-form-urlencoded")
|
|
126
|
+
) {
|
|
127
|
+
try {
|
|
128
|
+
const formData = await request.formData();
|
|
129
|
+
const obj: Record<string, unknown> = {};
|
|
130
|
+
for (const [key, value] of formData.entries()) {
|
|
131
|
+
if (obj[key] !== undefined) {
|
|
132
|
+
// Multiple values → array
|
|
133
|
+
const existing = Array.isArray(obj[key]) ? (obj[key] as unknown[]) : [obj[key]];
|
|
134
|
+
existing.push(value);
|
|
135
|
+
obj[key] = existing;
|
|
136
|
+
} else {
|
|
137
|
+
obj[key] = value;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return obj;
|
|
141
|
+
} catch {
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// URL-encoded search params (for GET fallback)
|
|
147
|
+
if (request.method === "GET") {
|
|
148
|
+
const url = new URL(request.url);
|
|
149
|
+
const propsParam = url.searchParams.get("props");
|
|
150
|
+
if (propsParam) {
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(decodeURIComponent(propsParam));
|
|
153
|
+
} catch {
|
|
154
|
+
return {};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return {};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// JSON (default for POST)
|
|
161
|
+
try {
|
|
162
|
+
return await request.json();
|
|
163
|
+
} catch {
|
|
164
|
+
return {};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function findHandler(
|
|
169
|
+
key: string,
|
|
170
|
+
): { handler: InvokeLoader | InvokeAction; type: "loader" | "action" } | null {
|
|
171
|
+
// 1. Check additive registry first (from registerInvokeHandlers / setupApps)
|
|
172
|
+
const registered = handlerRegistry.get(key);
|
|
173
|
+
if (registered) return { handler: registered, type: "action" };
|
|
174
|
+
|
|
175
|
+
// 2. Fall back to legacy getter-based registries
|
|
176
|
+
const loaders = getRegisteredLoaders();
|
|
177
|
+
if (loaders[key]) return { handler: loaders[key], type: "loader" };
|
|
178
|
+
|
|
179
|
+
const actions = getRegisteredActions();
|
|
180
|
+
if (actions[key]) return { handler: actions[key], type: "action" };
|
|
181
|
+
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Track which missing keys we've already warned about so dev console doesn't
|
|
186
|
+
// log the same warning every IntersectionObserver / nav loop iteration.
|
|
187
|
+
const warnedMissingKeys = new Set<string>();
|
|
188
|
+
|
|
189
|
+
function unknownHandlerMessage(key: string): string {
|
|
190
|
+
const isLoader = key.includes("/loaders/");
|
|
191
|
+
const isAction = key.includes("/actions/");
|
|
192
|
+
const kind = isAction ? "action" : isLoader ? "loader" : "loader/action";
|
|
193
|
+
const dir = isAction ? "src/actions/" : "src/loaders/";
|
|
194
|
+
const cliHint = "npm run generate:loaders";
|
|
195
|
+
const setupHint =
|
|
196
|
+
isAction
|
|
197
|
+
? "setInvokeActions(() => COMMERCE_ACTIONS)"
|
|
198
|
+
: "setInvokeLoaders(() => COMMERCE_LOADERS)";
|
|
199
|
+
return (
|
|
200
|
+
`Unknown handler: ${key}. ` +
|
|
201
|
+
`Check that the ${kind} exists in ${dir} (file path must match the key — ` +
|
|
202
|
+
`keys like "site/loaders/foo" resolve to ${dir}foo.ts) and that it is ` +
|
|
203
|
+
`included in src/server/cms/loaders.gen.ts. Regenerate with \`${cliHint}\` ` +
|
|
204
|
+
`or wire it manually via ${setupHint} in setup.ts. If you previously ran ` +
|
|
205
|
+
`the generator with --decofile-dir / --prune-by-decofile, drop the flag ` +
|
|
206
|
+
`to register code-driven loaders as well.`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function warnMissingHandlerOnce(key: string): void {
|
|
211
|
+
if (!isDev || warnedMissingKeys.has(key)) return;
|
|
212
|
+
warnedMissingKeys.add(key);
|
|
213
|
+
console.warn(`[invoke] ${unknownHandlerMessage(key)}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function handleInvoke(request: Request): Promise<Response> {
|
|
217
|
+
const url = new URL(request.url);
|
|
218
|
+
const pathParts = url.pathname.split("/deco/invoke/");
|
|
219
|
+
const invokeKey = pathParts[1] || "";
|
|
220
|
+
const select = url.searchParams.get("select")?.split(",").filter(Boolean);
|
|
221
|
+
|
|
222
|
+
const body = await parseBody(request);
|
|
223
|
+
|
|
224
|
+
// Single invoke by key
|
|
225
|
+
if (invokeKey) {
|
|
226
|
+
const found = findHandler(invokeKey);
|
|
227
|
+
if (!found) {
|
|
228
|
+
warnMissingHandlerOnce(invokeKey);
|
|
229
|
+
return errorResponse(unknownHandlerMessage(invokeKey), 404);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const result = await found.handler(body, request);
|
|
234
|
+
// Response passthrough: if the handler returns a Response object,
|
|
235
|
+
// forward it as-is (preserving headers like Set-Cookie).
|
|
236
|
+
if (result instanceof Response) {
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
const filtered = selectFields(result, select);
|
|
240
|
+
const response = new Response(JSON.stringify(filtered), { status: 200, headers: JSON_HEADERS });
|
|
241
|
+
forwardCtxHeadersTo(response);
|
|
242
|
+
return response;
|
|
243
|
+
} catch (error) {
|
|
244
|
+
return errorResponse((error as Error).message, 500, error);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Batch invoke
|
|
249
|
+
if (request.method === "POST" && body && typeof body === "object" && !Array.isArray(body)) {
|
|
250
|
+
const results: Record<string, unknown> = {};
|
|
251
|
+
|
|
252
|
+
const entries = Object.entries(body as Record<string, unknown>);
|
|
253
|
+
await Promise.all(
|
|
254
|
+
entries.map(async ([key, payload]) => {
|
|
255
|
+
const resolveType = (payload as any)?.__resolveType || key;
|
|
256
|
+
const found = findHandler(resolveType);
|
|
257
|
+
|
|
258
|
+
if (found) {
|
|
259
|
+
try {
|
|
260
|
+
let result = await found.handler(payload, request);
|
|
261
|
+
// If a loader returns a Response, extract its JSON body for batching.
|
|
262
|
+
// Set-Cookie values from a handler-returned Response are *not*
|
|
263
|
+
// forwarded — those leave the AsyncLocalStorage scope. Handlers
|
|
264
|
+
// that need cookie passthrough must write to
|
|
265
|
+
// RequestContext.responseHeaders (which forwardCtxHeadersTo()
|
|
266
|
+
// below propagates onto the batch response).
|
|
267
|
+
if (result instanceof Response) {
|
|
268
|
+
try { result = await result.json(); } catch { result = null; }
|
|
269
|
+
}
|
|
270
|
+
results[key] = selectFields(result, select);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
results[key] = { error: (error as Error).message };
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
warnMissingHandlerOnce(resolveType);
|
|
276
|
+
results[key] = { error: unknownHandlerMessage(resolveType) };
|
|
277
|
+
}
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const response = new Response(JSON.stringify(results), { status: 200, headers: JSON_HEADERS });
|
|
282
|
+
// All batch handlers share the same RequestContext, so any Set-Cookie
|
|
283
|
+
// they appended (e.g. from VTEX vtexFetchWithCookies) is in
|
|
284
|
+
// `responseHeaders` by now. Forward it as N distinct Set-Cookie headers.
|
|
285
|
+
forwardCtxHeadersTo(response);
|
|
286
|
+
return response;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return errorResponse("No invoke key specified", 400);
|
|
290
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin Live Controls - Inline script for storefront pages.
|
|
3
|
+
*
|
|
4
|
+
* When the storefront is embedded in the admin's iframe, the admin sends
|
|
5
|
+
* `editor::inject` postMessage events containing a script that handles
|
|
6
|
+
* the preview navigation loop. This listener receives that script and
|
|
7
|
+
* evaluates it in the page context.
|
|
8
|
+
*
|
|
9
|
+
* Include this script in the root layout (e.g., via a <script> tag) so
|
|
10
|
+
* the admin can communicate with the storefront.
|
|
11
|
+
*/
|
|
12
|
+
export const LIVE_CONTROLS_SCRIPT = `
|
|
13
|
+
(function() {
|
|
14
|
+
if (window.__DECO_LIVE_CONTROLS__) return;
|
|
15
|
+
window.__DECO_LIVE_CONTROLS__ = true;
|
|
16
|
+
|
|
17
|
+
addEventListener("message", function(event) {
|
|
18
|
+
var data = event.data;
|
|
19
|
+
if (!data || typeof data !== "object") return;
|
|
20
|
+
switch (data.type) {
|
|
21
|
+
case "editor::inject":
|
|
22
|
+
if (data.args && data.args.script) {
|
|
23
|
+
try { eval(data.args.script); } catch(e) { console.error("[deco] inject error:", e); }
|
|
24
|
+
}
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
})();
|
|
29
|
+
`;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { djb2Hex } from "@decocms/blocks/sdk/djb2";
|
|
2
|
+
import { composeMeta, type MetaResponse } from "@decocms/blocks/cms";
|
|
3
|
+
|
|
4
|
+
// Use globalThis to share meta state across module instances.
|
|
5
|
+
// The daemon middleware imports this module via native import() (outside Vite SSR),
|
|
6
|
+
// while setup.ts calls setMetaData() via Vite SSR — these are different module instances.
|
|
7
|
+
// globalThis bridges them so both see the same metaData.
|
|
8
|
+
const G = globalThis as unknown as {
|
|
9
|
+
__deco_meta_data?: MetaResponse | null;
|
|
10
|
+
__deco_meta_etag?: string | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function getMetaData(): MetaResponse | null {
|
|
14
|
+
return G.__deco_meta_data ?? null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function setMetaDataInternal(data: MetaResponse | null) {
|
|
18
|
+
G.__deco_meta_data = data;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getCachedEtag(): string | null {
|
|
22
|
+
return G.__deco_meta_etag ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function setCachedEtag(etag: string | null) {
|
|
26
|
+
G.__deco_meta_etag = etag;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Invalidate the cached ETag so the admin re-fetches meta after a
|
|
31
|
+
* hot-reload or decofile change.
|
|
32
|
+
*
|
|
33
|
+
* Called by decofile.ts after setBlocks() — no server-side loader import
|
|
34
|
+
* needed here, keeping this module safe for client-side bundles.
|
|
35
|
+
*/
|
|
36
|
+
export function invalidateMetaCache() {
|
|
37
|
+
setCachedEtag(null);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Set the schema metadata that /deco/meta will return.
|
|
42
|
+
* Runs composeMeta() to inject framework-level schemas (pages, etc.)
|
|
43
|
+
* on top of the site-generated section schemas.
|
|
44
|
+
*/
|
|
45
|
+
export function setMetaData(data: MetaResponse) {
|
|
46
|
+
setMetaDataInternal(composeMeta(data));
|
|
47
|
+
setCachedEtag(null);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Content-based hash for ETag.
|
|
52
|
+
* Uses DJB2 over the serialised JSON so any definition change
|
|
53
|
+
* results in a different ETag, forcing admin to re-fetch.
|
|
54
|
+
*/
|
|
55
|
+
function getEtag(): string {
|
|
56
|
+
let etag = getCachedEtag();
|
|
57
|
+
if (!etag) {
|
|
58
|
+
const str = JSON.stringify(getMetaData() || {});
|
|
59
|
+
etag = `"meta-${djb2Hex(str)}"`;
|
|
60
|
+
setCachedEtag(etag);
|
|
61
|
+
}
|
|
62
|
+
return etag;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function handleMeta(request: Request): Response {
|
|
66
|
+
const metaData = getMetaData();
|
|
67
|
+
if (!metaData) {
|
|
68
|
+
return new Response(JSON.stringify({ error: "Schema not initialized" }), {
|
|
69
|
+
status: 503,
|
|
70
|
+
headers: { "Content-Type": "application/json" },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const ifNoneMatch = request.headers.get("if-none-match");
|
|
75
|
+
const etag = getEtag();
|
|
76
|
+
|
|
77
|
+
if (ifNoneMatch === etag) {
|
|
78
|
+
return new Response(null, { status: 304, headers: { ETag: etag } });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const body = JSON.stringify({ ...metaData, etag });
|
|
82
|
+
|
|
83
|
+
return new Response(body, {
|
|
84
|
+
status: 200,
|
|
85
|
+
headers: {
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
ETag: etag,
|
|
88
|
+
"Cache-Control": "must-revalidate",
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import {
|
|
3
|
+
loadBlocks,
|
|
4
|
+
withBlocksOverride,
|
|
5
|
+
getSection,
|
|
6
|
+
type MatcherContext,
|
|
7
|
+
type ResolvedSection,
|
|
8
|
+
resolvePageSections,
|
|
9
|
+
resolveValue,
|
|
10
|
+
WELL_KNOWN_TYPES,
|
|
11
|
+
runSingleSectionLoader,
|
|
12
|
+
} from "@decocms/blocks/cms";
|
|
13
|
+
import { buildHtmlShell } from "../sdk/htmlShell";
|
|
14
|
+
import { LIVE_CONTROLS_SCRIPT } from "./liveControls";
|
|
15
|
+
import { getPreviewWrapper } from "./setup";
|
|
16
|
+
|
|
17
|
+
export { setRenderShell, setPreviewWrapper } from "./setup";
|
|
18
|
+
|
|
19
|
+
/** Escape user-controlled strings before interpolating into HTML. */
|
|
20
|
+
function escapeHtml(str: string): string {
|
|
21
|
+
return str
|
|
22
|
+
.replace(/&/g, "&")
|
|
23
|
+
.replace(/</g, "<")
|
|
24
|
+
.replace(/>/g, ">")
|
|
25
|
+
.replace(/"/g, """)
|
|
26
|
+
.replace(/'/g, "'");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Cache the dynamic import — avoids re-importing per section render
|
|
30
|
+
let _renderToString: ((element: any) => string) | null = null;
|
|
31
|
+
async function getRenderToString() {
|
|
32
|
+
if (!_renderToString) {
|
|
33
|
+
const mod = await import("react-dom/server");
|
|
34
|
+
_renderToString = mod.renderToString;
|
|
35
|
+
}
|
|
36
|
+
return _renderToString;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function wrapInHtmlShell(sectionHtml: string): string {
|
|
40
|
+
return buildHtmlShell({ body: sectionHtml, script: LIVE_CONTROLS_SCRIPT });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Render a single ResolvedSection to an HTML string.
|
|
45
|
+
* Uses the pre-cached renderToString and the preview wrapper.
|
|
46
|
+
*/
|
|
47
|
+
async function renderResolvedSection(section: ResolvedSection): Promise<string> {
|
|
48
|
+
const sectionLoader = getSection(section.component);
|
|
49
|
+
if (!sectionLoader) {
|
|
50
|
+
return `<div style="padding:8px;color:orange;font-size:12px;border:1px dashed orange;margin:4px 0;">Unsupported: ${escapeHtml(section.component)}</div>`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const renderToString = await getRenderToString();
|
|
55
|
+
const mod = await sectionLoader();
|
|
56
|
+
const element = createElement(mod.default, section.props);
|
|
57
|
+
const Wrapper = getPreviewWrapper();
|
|
58
|
+
const wrapped = Wrapper ? createElement(Wrapper, null, element) : element;
|
|
59
|
+
return renderToString(wrapped);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return `<div style="padding:8px;color:red;font-size:12px;">Error rendering ${escapeHtml(section.component)}: ${escapeHtml((error as Error).message)}</div>`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Render a single raw section object (with __resolveType) to HTML.
|
|
67
|
+
* Kept for the single-section preview path where we don't go through
|
|
68
|
+
* resolvePageSections.
|
|
69
|
+
*/
|
|
70
|
+
async function renderOneSection(section: Record<string, unknown>): Promise<string> {
|
|
71
|
+
const resolveType = section.__resolveType as string | undefined;
|
|
72
|
+
if (!resolveType) return "";
|
|
73
|
+
|
|
74
|
+
const sectionLoader = getSection(resolveType);
|
|
75
|
+
if (!sectionLoader) {
|
|
76
|
+
return `<div style="padding:8px;color:orange;font-size:12px;border:1px dashed orange;margin:4px 0;">Unsupported: ${escapeHtml(resolveType)}</div>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const { __resolveType: _, ...sectionProps } = section;
|
|
81
|
+
const renderToString = await getRenderToString();
|
|
82
|
+
const mod = await sectionLoader();
|
|
83
|
+
const element = createElement(mod.default, sectionProps);
|
|
84
|
+
const Wrapper = getPreviewWrapper();
|
|
85
|
+
const wrapped = Wrapper ? createElement(Wrapper, null, element) : element;
|
|
86
|
+
return renderToString(wrapped);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return `<div style="padding:8px;color:red;font-size:12px;">Error rendering ${escapeHtml(resolveType)}: ${escapeHtml((error as Error).message)}</div>`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build a MatcherContext from the preview request.
|
|
94
|
+
* Enables matchers (device, date, cookie, etc.) to evaluate correctly
|
|
95
|
+
* during preview resolution.
|
|
96
|
+
*/
|
|
97
|
+
function buildPreviewMatcherCtx(request: Request): MatcherContext {
|
|
98
|
+
const url = new URL(request.url);
|
|
99
|
+
const deviceHint = url.searchParams.get("deviceHint");
|
|
100
|
+
const path = url.searchParams.get("path") || "/";
|
|
101
|
+
|
|
102
|
+
let userAgent = request.headers.get("user-agent") ?? "";
|
|
103
|
+
if (deviceHint === "mobile" && !/mobile/i.test(userAgent)) {
|
|
104
|
+
userAgent += " Mobile";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const cookieHeader = request.headers.get("cookie") ?? "";
|
|
108
|
+
const cookies: Record<string, string> = {};
|
|
109
|
+
for (const part of cookieHeader.split(";")) {
|
|
110
|
+
const [k, ...v] = part.trim().split("=");
|
|
111
|
+
if (k) cookies[k.trim()] = v.join("=").trim();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
userAgent,
|
|
116
|
+
url: url.toString(),
|
|
117
|
+
path,
|
|
118
|
+
cookies,
|
|
119
|
+
request,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Handles /live/previews/* -- renders sections to HTML for the admin preview.
|
|
125
|
+
*
|
|
126
|
+
* Supports:
|
|
127
|
+
* - Page compositor (website/pages/Page.tsx): resolves + renders all child sections
|
|
128
|
+
* - Single section render with full __resolveType resolution
|
|
129
|
+
* - Per-request decofile override via AsyncLocalStorage
|
|
130
|
+
*/
|
|
131
|
+
export async function handleRender(request: Request): Promise<Response> {
|
|
132
|
+
const url = new URL(request.url);
|
|
133
|
+
const resolveChain = url.searchParams.get("resolveChain");
|
|
134
|
+
const propsParam = url.searchParams.get("props");
|
|
135
|
+
console.log("handleRender", request.url);
|
|
136
|
+
|
|
137
|
+
const pathPrefix = "/live/previews/";
|
|
138
|
+
const rawPathComponent = url.pathname.startsWith(pathPrefix)
|
|
139
|
+
? url.pathname.slice(pathPrefix.length)
|
|
140
|
+
: "";
|
|
141
|
+
// Admin sends double-encoded path segments (e.g. space → %20 → %2520).
|
|
142
|
+
// Decode until stable so the block key matches the decofile.
|
|
143
|
+
const pathComponent = (() => {
|
|
144
|
+
try {
|
|
145
|
+
const once = decodeURIComponent(rawPathComponent);
|
|
146
|
+
try { return decodeURIComponent(once); } catch { return once; }
|
|
147
|
+
} catch { return rawPathComponent; }
|
|
148
|
+
})();
|
|
149
|
+
let component = resolveChain || pathComponent || "";
|
|
150
|
+
let props: Record<string, unknown> = {};
|
|
151
|
+
let decofileOverride: Record<string, unknown> | null = null;
|
|
152
|
+
|
|
153
|
+
if (request.method === "POST") {
|
|
154
|
+
try {
|
|
155
|
+
const body = await request.json();
|
|
156
|
+
if (body && typeof body === "object") {
|
|
157
|
+
if (body.__decofile && typeof body.__decofile === "object") {
|
|
158
|
+
decofileOverride = body.__decofile;
|
|
159
|
+
}
|
|
160
|
+
if (body.__props && typeof body.__props === "object") {
|
|
161
|
+
props = body.__props;
|
|
162
|
+
if (body.__props.__resolveType) {
|
|
163
|
+
component = body.__props.__resolveType as string;
|
|
164
|
+
}
|
|
165
|
+
} else if (body.props && typeof body.props === "object") {
|
|
166
|
+
props = body.props;
|
|
167
|
+
} else if (body.__resolveType) {
|
|
168
|
+
component = body.__resolveType as string;
|
|
169
|
+
const { __decofile: _, __resolveType: __, ...rest } = body;
|
|
170
|
+
props = rest;
|
|
171
|
+
} else if (!body.__decofile) {
|
|
172
|
+
props = body;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// fall through to query-param handling
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!decofileOverride) {
|
|
181
|
+
const decofileParam = url.searchParams.get("__decofile");
|
|
182
|
+
if (decofileParam) {
|
|
183
|
+
try {
|
|
184
|
+
decofileOverride = JSON.parse(decodeURIComponent(decofileParam));
|
|
185
|
+
} catch {
|
|
186
|
+
// invalid __decofile param, ignore
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (propsParam && Object.keys(props).length === 0) {
|
|
192
|
+
try {
|
|
193
|
+
props = JSON.parse(decodeURIComponent(propsParam));
|
|
194
|
+
} catch {
|
|
195
|
+
// Try base64-encoded props (admin uses btoa(encodeURIComponent(json)))
|
|
196
|
+
try {
|
|
197
|
+
props = JSON.parse(decodeURIComponent(atob(propsParam)));
|
|
198
|
+
} catch {
|
|
199
|
+
// props parsing failed
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (props.__resolveType && !component) {
|
|
205
|
+
component = props.__resolveType as string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const renderFn = async () => {
|
|
209
|
+
const blocks = loadBlocks();
|
|
210
|
+
|
|
211
|
+
// Resolve named block at the component level
|
|
212
|
+
if (blocks[component]) {
|
|
213
|
+
const block = blocks[component] as Record<string, unknown>;
|
|
214
|
+
if (block.__resolveType) {
|
|
215
|
+
component = block.__resolveType as string;
|
|
216
|
+
props = { ...block, ...props };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Page compositor: resolve + render all child sections in parallel
|
|
221
|
+
if (component === WELL_KNOWN_TYPES.PAGE) {
|
|
222
|
+
const matcherCtx = buildPreviewMatcherCtx(request);
|
|
223
|
+
|
|
224
|
+
// resolvePageSections uses the same strategy as resolveDecoPage:
|
|
225
|
+
// parallel section resolution, layout caching, in-flight dedup, memoization
|
|
226
|
+
const resolvedSections = await resolvePageSections(
|
|
227
|
+
props.sections,
|
|
228
|
+
matcherCtx,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// Run section loaders in parallel — benefits from layout and cacheable caches
|
|
232
|
+
const enrichedSections = await Promise.all(
|
|
233
|
+
resolvedSections.map((section) =>
|
|
234
|
+
runSingleSectionLoader(section, request).catch(() => section),
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// Render all sections in parallel
|
|
239
|
+
const htmlParts = await Promise.all(
|
|
240
|
+
enrichedSections.map((section) => renderResolvedSection(section)),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
return new Response(wrapInHtmlShell(htmlParts.filter(Boolean).join("\n")), {
|
|
244
|
+
status: 200,
|
|
245
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Single section render
|
|
250
|
+
const sectionLoader = getSection(component);
|
|
251
|
+
if (!sectionLoader) {
|
|
252
|
+
const unknownHtml = wrapInHtmlShell(
|
|
253
|
+
`<div style="padding:20px;color:red;">Unknown section: ${escapeHtml(component)}</div>`,
|
|
254
|
+
);
|
|
255
|
+
return new Response(unknownHtml, {
|
|
256
|
+
status: 200,
|
|
257
|
+
headers: { "Content-Type": "text/html" },
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const resolvedProps = (await resolveValue(props)) as Record<string, unknown>;
|
|
263
|
+
const { __resolveType: _, ...cleanProps } = resolvedProps;
|
|
264
|
+
const renderToString = await getRenderToString();
|
|
265
|
+
const mod = await sectionLoader();
|
|
266
|
+
const element = createElement(mod.default, cleanProps);
|
|
267
|
+
const Wrapper = getPreviewWrapper();
|
|
268
|
+
const wrapped = Wrapper ? createElement(Wrapper, null, element) : element;
|
|
269
|
+
const sectionHtml = renderToString(wrapped);
|
|
270
|
+
return new Response(wrapInHtmlShell(sectionHtml), {
|
|
271
|
+
status: 200,
|
|
272
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
273
|
+
});
|
|
274
|
+
} catch (error) {
|
|
275
|
+
const errorHtml = wrapInHtmlShell(
|
|
276
|
+
`<div style="padding:20px;color:red;">Render error: ${escapeHtml((error as Error).message)}</div>`,
|
|
277
|
+
);
|
|
278
|
+
return new Response(errorHtml, {
|
|
279
|
+
status: 200,
|
|
280
|
+
headers: { "Content-Type": "text/html" },
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
if (decofileOverride) {
|
|
286
|
+
return withBlocksOverride(decofileOverride, renderFn);
|
|
287
|
+
}
|
|
288
|
+
return renderFn();
|
|
289
|
+
}
|