@backmatter/next 0.0.21 → 0.0.23
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/dist/config.cjs +71 -6
- package/dist/loader.cjs +71 -5
- package/package.json +12 -5
- package/src/cms-proxy.ts +166 -0
- package/src/config.ts +7 -1
- package/src/css.d.ts +2 -0
- package/src/devbar-client.tsx +136 -17
- package/src/devbar.tsx +20 -3
- package/src/gate.ts +85 -0
- package/src/layout-doc.ts +38 -0
- package/src/loader.ts +6 -2
- package/src/page-render.ts +7 -3
- package/src/preview-content.ts +49 -0
- package/src/preview-live.tsx +9 -0
- package/src/preview-refresh.tsx +211 -3
- package/src/preview-route.ts +90 -21
- package/src/render-preview.ts +186 -0
- package/src/request-url.ts +13 -0
- package/src/revalidate.ts +2 -2
- package/src/routes.ts +207 -45
- package/src/site-channel.ts +37 -0
- package/src/site-doc-bridge.tsx +50 -0
- package/src/site-doc.ts +123 -0
- package/src/site-editor.tsx +353 -0
- package/src/source-data.ts +42 -0
- package/src/studio-client.tsx +62 -0
- package/src/studio.tsx +144 -0
package/src/preview-refresh.tsx
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
// packages/next/src/preview-refresh.tsx
|
|
2
2
|
"use client";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { useCallback, useEffect, useState, startTransition, type ReactNode } from "react";
|
|
5
5
|
import { useRouter } from "next/navigation";
|
|
6
|
-
import {
|
|
6
|
+
import { PREVIEW_CHECKOUT_QUERY } from "@backmatter/core";
|
|
7
|
+
import {
|
|
8
|
+
createPreviewPage,
|
|
9
|
+
createWindowPreviewTransport,
|
|
10
|
+
type PreviewChannelRef,
|
|
11
|
+
type PreviewRenderDoc,
|
|
12
|
+
type PreviewTransport,
|
|
13
|
+
} from "@backmatter/core/preview";
|
|
14
|
+
import { fanInPreviewTransports, sitePageTransport } from "./site-channel";
|
|
15
|
+
import { PreviewPageReloader as PreviewStreamReloader, PreviewRemoved } from "./preview-live";
|
|
7
16
|
|
|
8
17
|
/** Keeps the current page visible while preview content is refetched. */
|
|
9
18
|
export function PreviewPageReloader(props: { locale: string; type: string; slug: string }) {
|
|
@@ -13,5 +22,204 @@ export function PreviewPageReloader(props: { locale: string; type: string; slug:
|
|
|
13
22
|
router.refresh();
|
|
14
23
|
});
|
|
15
24
|
}, [router]);
|
|
16
|
-
return
|
|
25
|
+
return <PreviewStreamReloader {...props} refresh={refresh} />;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type PreviewRenderAction = (
|
|
29
|
+
ref: PreviewChannelRef,
|
|
30
|
+
doc: PreviewRenderDoc,
|
|
31
|
+
checkoutToken?: string,
|
|
32
|
+
) => Promise<{ ok: true; node: ReactNode } | { ok: false; error: string }>;
|
|
33
|
+
|
|
34
|
+
function referrerOrigin(): string {
|
|
35
|
+
try {
|
|
36
|
+
return document.referrer ? new URL(document.referrer).origin : "";
|
|
37
|
+
} catch {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function subscribeDocStream(
|
|
43
|
+
ref: { locale: string; type: string; slug: string },
|
|
44
|
+
onEvent: (event: { raw?: string; body?: string; frontmatter?: Record<string, unknown> }) => void,
|
|
45
|
+
): () => void {
|
|
46
|
+
const params = new URLSearchParams({
|
|
47
|
+
stream: "1",
|
|
48
|
+
locale: ref.locale,
|
|
49
|
+
type: ref.type,
|
|
50
|
+
slug: ref.slug,
|
|
51
|
+
});
|
|
52
|
+
const checkout = new URLSearchParams(globalThis.location?.search ?? "").get(PREVIEW_CHECKOUT_QUERY);
|
|
53
|
+
if (checkout) params.set(PREVIEW_CHECKOUT_QUERY, checkout);
|
|
54
|
+
const source = new EventSource(`/api/backmatter/preview?${params}`);
|
|
55
|
+
const onChange = (ev: MessageEvent) => {
|
|
56
|
+
try {
|
|
57
|
+
const data = JSON.parse(String(ev.data)) as {
|
|
58
|
+
raw?: unknown;
|
|
59
|
+
body?: unknown;
|
|
60
|
+
frontmatter?: unknown;
|
|
61
|
+
};
|
|
62
|
+
onEvent({
|
|
63
|
+
...(typeof data.raw === "string" ? { raw: data.raw } : {}),
|
|
64
|
+
...(typeof data.body === "string" ? { body: data.body } : {}),
|
|
65
|
+
...(data.frontmatter && typeof data.frontmatter === "object" && !Array.isArray(data.frontmatter)
|
|
66
|
+
? { frontmatter: data.frontmatter as Record<string, unknown> }
|
|
67
|
+
: {}),
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
// ignore malformed
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
source.addEventListener("change", onChange as EventListener);
|
|
74
|
+
return () => {
|
|
75
|
+
source.removeEventListener("change", onChange as EventListener);
|
|
76
|
+
source.close();
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Refresh when some other document in this checkout changes, or HEAD moves. */
|
|
81
|
+
function subscribeTreeStream(
|
|
82
|
+
ref: { locale: string; type: string; slug: string },
|
|
83
|
+
refresh: () => void,
|
|
84
|
+
): () => void {
|
|
85
|
+
const params = new URLSearchParams({ stream: "tree" });
|
|
86
|
+
const checkout = new URLSearchParams(globalThis.location?.search ?? "").get(PREVIEW_CHECKOUT_QUERY);
|
|
87
|
+
if (checkout) params.set(PREVIEW_CHECKOUT_QUERY, checkout);
|
|
88
|
+
const source = new EventSource(`/api/backmatter/preview?${params}`);
|
|
89
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
90
|
+
const schedule = () => {
|
|
91
|
+
if (timer) return;
|
|
92
|
+
timer = setTimeout(() => {
|
|
93
|
+
timer = null;
|
|
94
|
+
refresh();
|
|
95
|
+
}, 50);
|
|
96
|
+
};
|
|
97
|
+
const onChange = (ev: MessageEvent) => {
|
|
98
|
+
try {
|
|
99
|
+
const data = JSON.parse(String(ev.data)) as { ref?: { locale?: string; type?: string; slug?: string } };
|
|
100
|
+
const changed = data.ref;
|
|
101
|
+
if (
|
|
102
|
+
changed &&
|
|
103
|
+
changed.locale === ref.locale &&
|
|
104
|
+
changed.type === ref.type &&
|
|
105
|
+
changed.slug === ref.slug
|
|
106
|
+
) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// a malformed payload still means something in the tree moved
|
|
111
|
+
}
|
|
112
|
+
schedule();
|
|
113
|
+
};
|
|
114
|
+
source.addEventListener("change", onChange as EventListener);
|
|
115
|
+
source.addEventListener("head", schedule as EventListener);
|
|
116
|
+
return () => {
|
|
117
|
+
if (timer) clearTimeout(timer);
|
|
118
|
+
source.removeEventListener("change", onChange as EventListener);
|
|
119
|
+
source.removeEventListener("head", schedule as EventListener);
|
|
120
|
+
source.close();
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Applies editor `doc` messages and SSE payloads through `renderPreview`
|
|
126
|
+
* and swaps the body. First paint is `children`.
|
|
127
|
+
*/
|
|
128
|
+
export function PreviewClient({
|
|
129
|
+
locale,
|
|
130
|
+
type,
|
|
131
|
+
slug,
|
|
132
|
+
branch,
|
|
133
|
+
tree,
|
|
134
|
+
dashboardOrigins = [],
|
|
135
|
+
renderPreview,
|
|
136
|
+
children,
|
|
137
|
+
}: {
|
|
138
|
+
locale: string;
|
|
139
|
+
type: string;
|
|
140
|
+
slug: string;
|
|
141
|
+
branch?: string;
|
|
142
|
+
tree?: string;
|
|
143
|
+
dashboardOrigins?: string[];
|
|
144
|
+
renderPreview: PreviewRenderAction;
|
|
145
|
+
children?: ReactNode;
|
|
146
|
+
}) {
|
|
147
|
+
const router = useRouter();
|
|
148
|
+
const [node, setNode] = useState<ReactNode>(null);
|
|
149
|
+
const [removed, setRemoved] = useState(false);
|
|
150
|
+
const [error, setError] = useState<string | null>(null);
|
|
151
|
+
const originsKey = dashboardOrigins.join("\0");
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
const ref: PreviewChannelRef = {
|
|
155
|
+
locale,
|
|
156
|
+
type,
|
|
157
|
+
slug,
|
|
158
|
+
...(branch ? { branch } : {}),
|
|
159
|
+
...(tree ? { tree } : {}),
|
|
160
|
+
};
|
|
161
|
+
// Framed by the dashboard's split view: the parent is the dashboard.
|
|
162
|
+
// Otherwise the Studio mounted on this site posts from same-origin frames.
|
|
163
|
+
const parentOrigin = referrerOrigin();
|
|
164
|
+
const selfOrigin = window.location.origin;
|
|
165
|
+
const allowed = [...originsKey.split("\0"), parentOrigin, selfOrigin].filter(
|
|
166
|
+
(origin) => origin.length > 0,
|
|
167
|
+
);
|
|
168
|
+
const framed = window.parent !== window;
|
|
169
|
+
const targetOrigin = framed ? parentOrigin || allowed[0] || "" : selfOrigin;
|
|
170
|
+
const windowTransport: PreviewTransport | null = targetOrigin
|
|
171
|
+
? createWindowPreviewTransport({
|
|
172
|
+
target: framed ? window.parent : { postMessage() {} },
|
|
173
|
+
targetOrigin,
|
|
174
|
+
allowedOrigins: allowed,
|
|
175
|
+
})
|
|
176
|
+
: null;
|
|
177
|
+
const transport = fanInPreviewTransports(
|
|
178
|
+
windowTransport ? [windowTransport, sitePageTransport()] : [sitePageTransport()],
|
|
179
|
+
);
|
|
180
|
+
const checkoutToken =
|
|
181
|
+
new URLSearchParams(window.location.search).get(PREVIEW_CHECKOUT_QUERY) ?? undefined;
|
|
182
|
+
const refreshOthers = () => {
|
|
183
|
+
startTransition(() => router.refresh());
|
|
184
|
+
};
|
|
185
|
+
const unsubscribeTree = subscribeTreeStream(ref, refreshOthers);
|
|
186
|
+
const session = createPreviewPage({
|
|
187
|
+
ref,
|
|
188
|
+
transport,
|
|
189
|
+
subscribeSse: (onEvent) => subscribeDocStream(ref, onEvent),
|
|
190
|
+
renderer: async (_ref, doc) => {
|
|
191
|
+
const result = await renderPreview(ref, doc, checkoutToken);
|
|
192
|
+
if (!result.ok) return result;
|
|
193
|
+
return { ok: true, value: result.node };
|
|
194
|
+
},
|
|
195
|
+
handlers: {
|
|
196
|
+
onApplied: (_doc, value) => {
|
|
197
|
+
setError(null);
|
|
198
|
+
setRemoved(false);
|
|
199
|
+
setNode(value as ReactNode);
|
|
200
|
+
},
|
|
201
|
+
onError: (message) => setError(message),
|
|
202
|
+
onRemoved: () => {
|
|
203
|
+
setRemoved(true);
|
|
204
|
+
setError(null);
|
|
205
|
+
},
|
|
206
|
+
onNavigate: (path) => {
|
|
207
|
+
startTransition(() => router.push(path));
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
return () => {
|
|
212
|
+
unsubscribeTree();
|
|
213
|
+
session.close();
|
|
214
|
+
windowTransport?.close();
|
|
215
|
+
};
|
|
216
|
+
}, [locale, type, slug, branch, tree, renderPreview, router, originsKey]);
|
|
217
|
+
|
|
218
|
+
if (removed) return <PreviewRemoved />;
|
|
219
|
+
return (
|
|
220
|
+
<div data-backmatter-preview="">
|
|
221
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
222
|
+
{node ?? children}
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
17
225
|
}
|
package/src/preview-route.ts
CHANGED
|
@@ -3,28 +3,48 @@ import {
|
|
|
3
3
|
PREVIEW_CHECKOUT_QUERY,
|
|
4
4
|
PREVIEW_COOKIE,
|
|
5
5
|
PREVIEW_COOKIE_TTL_MS,
|
|
6
|
+
PREVIEW_SESSION_COOKIE,
|
|
7
|
+
PREVIEW_SESSION_TTL_MS,
|
|
6
8
|
previewLocationWithCheckout,
|
|
7
9
|
readPreviewCookie,
|
|
8
10
|
readPreviewTicket,
|
|
11
|
+
resolveDashboardUrl,
|
|
12
|
+
studioEnterUrl,
|
|
9
13
|
writePreviewCookie,
|
|
10
14
|
type PreviewCheckout,
|
|
11
15
|
} from "@backmatter/core";
|
|
12
|
-
import { previewStreamUrl } from "@backmatter/core/preview";
|
|
16
|
+
import { previewStreamUrl, previewTreeStreamUrl } from "@backmatter/core/preview";
|
|
13
17
|
import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
|
|
18
|
+
import { MARKER_COOKIE, MARKER_TTL_S, safeReturnPath } from "./gate";
|
|
19
|
+
import { requestUrlFrom } from "./request-url";
|
|
14
20
|
|
|
15
21
|
export type PreviewRouteDeps = {
|
|
16
22
|
enableDraft?: () => void | Promise<void>;
|
|
17
23
|
disableDraft?: () => void | Promise<void>;
|
|
18
24
|
isDraftEnabled?: () => boolean | Promise<boolean>;
|
|
19
25
|
now?: () => number;
|
|
26
|
+
/** Dashboard that holds the login, for `?enter=1`. Default: `resolveDashboardUrl()`. */
|
|
27
|
+
dashboardUrl?: string;
|
|
20
28
|
};
|
|
21
29
|
|
|
22
|
-
|
|
30
|
+
/** The gate's marker: readable by the page script, so never HttpOnly. */
|
|
31
|
+
function markerCookieHeader(secure: boolean, on: boolean): string {
|
|
32
|
+
const parts = [
|
|
33
|
+
`${MARKER_COOKIE}=${on ? "1" : ""}`,
|
|
34
|
+
"Path=/",
|
|
35
|
+
`Max-Age=${on ? MARKER_TTL_S : 0}`,
|
|
36
|
+
"SameSite=Lax",
|
|
37
|
+
];
|
|
38
|
+
if (secure) parts.push("Secure");
|
|
39
|
+
return parts.join("; ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function cookieBase(name: string, secure: boolean, maxAge: number, value: string) {
|
|
23
43
|
// HTTPS is cross-site from the CMS iframe (dash → customer origin). Lax is
|
|
24
44
|
// not sent there, so draftMode can be on while the checkout cookie is missing
|
|
25
45
|
// (`invalid_session`). Partitioned (CHIPS) keeps the cookie in that embed.
|
|
26
46
|
const parts = [
|
|
27
|
-
`${
|
|
47
|
+
`${name}=${value}`,
|
|
28
48
|
"Path=/",
|
|
29
49
|
"HttpOnly",
|
|
30
50
|
`Max-Age=${maxAge}`,
|
|
@@ -53,19 +73,19 @@ async function nextDraftEnabled(): Promise<boolean> {
|
|
|
53
73
|
return (await mod.draftMode()).isEnabled;
|
|
54
74
|
}
|
|
55
75
|
|
|
56
|
-
function cookieFrom(req: Request): string | undefined {
|
|
76
|
+
function cookieFrom(req: Request, name: string): string | undefined {
|
|
57
77
|
const header = req.headers.get("cookie");
|
|
58
78
|
if (!header) return undefined;
|
|
59
79
|
for (const part of header.split(";")) {
|
|
60
|
-
const [
|
|
61
|
-
if (
|
|
80
|
+
const [key, ...rest] = part.trim().split("=");
|
|
81
|
+
if (key === name) return rest.join("=");
|
|
62
82
|
}
|
|
63
83
|
return undefined;
|
|
64
84
|
}
|
|
65
85
|
|
|
66
86
|
async function checkoutFrom(req: Request, secret: string, url: URL): Promise<PreviewCheckout | null> {
|
|
67
87
|
return (
|
|
68
|
-
(await readPreviewCookie(secret, cookieFrom(req))) ??
|
|
88
|
+
(await readPreviewCookie(secret, cookieFrom(req, PREVIEW_COOKIE))) ??
|
|
69
89
|
(await readPreviewCookie(secret, url.searchParams.get(PREVIEW_CHECKOUT_QUERY)?.trim()))
|
|
70
90
|
);
|
|
71
91
|
}
|
|
@@ -93,6 +113,23 @@ export function createPreviewRoute(
|
|
|
93
113
|
return Response.json({ mode: draftOn ? "preview" : "live" });
|
|
94
114
|
}
|
|
95
115
|
|
|
116
|
+
// The bar found the marker but no session: go top-level to the dashboard,
|
|
117
|
+
// which mints a ticket after login and returns to this page.
|
|
118
|
+
if (url.searchParams.get("enter") === "1") {
|
|
119
|
+
if (!cms.config.workspaceId) {
|
|
120
|
+
return Response.json({ error: "preview_not_configured" }, { status: 503 });
|
|
121
|
+
}
|
|
122
|
+
const returnUrl = requestUrlFrom(
|
|
123
|
+
(name) => req.headers.get(name),
|
|
124
|
+
safeReturnPath(url.searchParams.get("return")),
|
|
125
|
+
);
|
|
126
|
+
const dashboardUrl = resolveDashboardUrl(deps.dashboardUrl);
|
|
127
|
+
return Response.redirect(
|
|
128
|
+
studioEnterUrl(dashboardUrl, { workspaceId: cms.config.workspaceId, returnUrl }),
|
|
129
|
+
302,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
96
133
|
if (url.searchParams.get("stream") === "1") {
|
|
97
134
|
if (!secret || !cms.config.apiUrl || !cms.config.workspaceId) {
|
|
98
135
|
return new Response("preview not configured", { status: 503 });
|
|
@@ -117,6 +154,26 @@ export function createPreviewRoute(
|
|
|
117
154
|
});
|
|
118
155
|
}
|
|
119
156
|
|
|
157
|
+
if (url.searchParams.get("stream") === "tree") {
|
|
158
|
+
if (!secret || !cms.config.apiUrl || !cms.config.workspaceId) {
|
|
159
|
+
return new Response("preview not configured", { status: 503 });
|
|
160
|
+
}
|
|
161
|
+
const checkout = await checkoutFrom(req, secret, url);
|
|
162
|
+
if (!checkout) return new Response("preview session missing", { status: 401 });
|
|
163
|
+
const upstream = previewTreeStreamUrl(
|
|
164
|
+
{ apiUrl: cms.config.apiUrl, workspaceId: cms.config.workspaceId, previewToken: secret },
|
|
165
|
+
checkout,
|
|
166
|
+
);
|
|
167
|
+
const res = await fetch(upstream);
|
|
168
|
+
return new Response(res.body, {
|
|
169
|
+
status: res.status,
|
|
170
|
+
headers: {
|
|
171
|
+
"Content-Type": "text/event-stream",
|
|
172
|
+
"Cache-Control": "no-store",
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
120
177
|
const ticket = url.searchParams.get("ticket")?.trim();
|
|
121
178
|
if (!ticket) return Response.json({ error: "missing_ticket" }, { status: 400 });
|
|
122
179
|
if (!secret) return Response.json({ error: "preview_not_configured" }, { status: 503 });
|
|
@@ -131,24 +188,36 @@ export function createPreviewRoute(
|
|
|
131
188
|
});
|
|
132
189
|
await (deps.enableDraft ?? (() => nextDraft(true)))();
|
|
133
190
|
const location = previewLocationWithCheckout(url.origin, payload.redirect, value);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
191
|
+
const headers = new Headers({ Location: location });
|
|
192
|
+
headers.append(
|
|
193
|
+
"Set-Cookie",
|
|
194
|
+
cookieBase(PREVIEW_COOKIE, secure, Math.floor(PREVIEW_COOKIE_TTL_MS / 1000), value),
|
|
195
|
+
);
|
|
196
|
+
if (payload.session) {
|
|
197
|
+
headers.append(
|
|
198
|
+
"Set-Cookie",
|
|
199
|
+
cookieBase(
|
|
200
|
+
PREVIEW_SESSION_COOKIE,
|
|
201
|
+
secure,
|
|
202
|
+
Math.floor(PREVIEW_SESSION_TTL_MS / 1000),
|
|
203
|
+
payload.session,
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
// Entry opts the visitor in, so the bar shows on later pages without a param.
|
|
208
|
+
headers.append("Set-Cookie", markerCookieHeader(secure, true));
|
|
209
|
+
return new Response(null, { status: 302, headers });
|
|
141
210
|
};
|
|
142
211
|
|
|
143
212
|
const POST = async (req: Request): Promise<Response> => {
|
|
144
213
|
await (deps.disableDraft ?? (() => nextDraft(false)))();
|
|
145
214
|
const secure = new URL(req.url).protocol === "https:";
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
});
|
|
215
|
+
const headers = new Headers();
|
|
216
|
+
headers.append("Set-Cookie", cookieBase(PREVIEW_COOKIE, secure, 0, ""));
|
|
217
|
+
headers.append("Set-Cookie", cookieBase(PREVIEW_SESSION_COOKIE, secure, 0, ""));
|
|
218
|
+
// Exit also drops the marker; otherwise the next page would bounce straight back in.
|
|
219
|
+
headers.append("Set-Cookie", markerCookieHeader(secure, false));
|
|
220
|
+
return new Response(null, { status: 204, headers });
|
|
152
221
|
};
|
|
153
222
|
|
|
154
223
|
return { GET, POST };
|
|
@@ -160,5 +229,5 @@ export async function loadPreviewCheckout(
|
|
|
160
229
|
): Promise<PreviewCheckout | null> {
|
|
161
230
|
if (!secret) return null;
|
|
162
231
|
const fake = new Request("http://local", { headers: cookieHeader ? { cookie: cookieHeader } : {} });
|
|
163
|
-
return readPreviewCookie(secret, cookieFrom(fake));
|
|
232
|
+
return readPreviewCookie(secret, cookieFrom(fake, PREVIEW_COOKIE));
|
|
164
233
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// packages/next/src/render-preview.ts
|
|
2
|
+
// Server action factory. Renders the editor's document with the host's
|
|
3
|
+
// components. Refuses unless draft mode is on and the checkout tree matches.
|
|
4
|
+
import {
|
|
5
|
+
PREVIEW_COOKIE,
|
|
6
|
+
PREVIEW_SESSION_COOKIE,
|
|
7
|
+
readPreviewCookie,
|
|
8
|
+
readPreviewSession,
|
|
9
|
+
type ModelDef,
|
|
10
|
+
type PreviewSessionClaims,
|
|
11
|
+
} from "@backmatter/core";
|
|
12
|
+
import type { ReactNode } from "react";
|
|
13
|
+
import { renderLayoutDoc, type LayoutDocOptions } from "./layout-doc";
|
|
14
|
+
import { previewContentForCheckout, runWithPreviewContent } from "./preview-content";
|
|
15
|
+
import { requireSource, resolveSourceData } from "./source-data";
|
|
16
|
+
|
|
17
|
+
export const RENDER_PREVIEW_PER_SECOND = 20;
|
|
18
|
+
|
|
19
|
+
export type RenderPreviewRef = {
|
|
20
|
+
locale: string;
|
|
21
|
+
type: string;
|
|
22
|
+
slug: string;
|
|
23
|
+
branch?: string;
|
|
24
|
+
tree?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type RenderPreviewDoc = {
|
|
28
|
+
frontmatter: Record<string, unknown>;
|
|
29
|
+
body: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type RenderPreviewResult =
|
|
33
|
+
| { ok: true; node: ReactNode }
|
|
34
|
+
| { ok: false; error: string };
|
|
35
|
+
|
|
36
|
+
export type RenderPreviewDeps = {
|
|
37
|
+
draftEnabled?: () => Promise<boolean> | boolean;
|
|
38
|
+
/** Checkout cookie value. The action argument is used when this is missing. */
|
|
39
|
+
readCheckoutToken?: () => Promise<string | undefined> | string | undefined;
|
|
40
|
+
/** Preview session cookie. Required. */
|
|
41
|
+
readSessionToken?: () => Promise<string | undefined> | string | undefined;
|
|
42
|
+
/** Epoch check. Default asks the CMS when apiUrl is set, and trusts the signature otherwise. */
|
|
43
|
+
sessionFresh?: (token: string, claims: PreviewSessionClaims) => Promise<boolean>;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
limitPerSecond?: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type CmsLike = {
|
|
49
|
+
config: { previewToken?: string; apiUrl?: string; workspaceId?: string };
|
|
50
|
+
models: readonly ModelDef[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const freshUntil = new Map<string, number>();
|
|
54
|
+
|
|
55
|
+
async function defaultSessionFresh(apiUrl: string, token: string): Promise<boolean> {
|
|
56
|
+
const until = freshUntil.get(token);
|
|
57
|
+
if (until && until > Date.now()) return true;
|
|
58
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/preview/session`, {
|
|
59
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
60
|
+
cache: "no-store",
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok) return false;
|
|
63
|
+
freshUntil.set(token, Date.now() + 30_000);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const hits = new Map<string, number[]>();
|
|
68
|
+
|
|
69
|
+
export function resetRenderPreviewLimits(): void {
|
|
70
|
+
hits.clear();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function allowRender(key: string, now: number, limit: number): boolean {
|
|
74
|
+
const recent = (hits.get(key) ?? []).filter((at) => now - at < 1000);
|
|
75
|
+
if (recent.length >= limit) {
|
|
76
|
+
hits.set(key, recent);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
recent.push(now);
|
|
80
|
+
hits.set(key, recent);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function defaultDraftEnabled(): Promise<boolean> {
|
|
85
|
+
const mod = (await import("next/headers" as string)) as {
|
|
86
|
+
draftMode: () => Promise<{ isEnabled: boolean }>;
|
|
87
|
+
};
|
|
88
|
+
return (await mod.draftMode()).isEnabled;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function cookieValue(name: string): Promise<string | undefined> {
|
|
92
|
+
const mod = (await import("next/headers" as string)) as {
|
|
93
|
+
cookies: () => Promise<{ get: (cookieName: string) => { value: string } | undefined }>;
|
|
94
|
+
};
|
|
95
|
+
return (await mod.cookies()).get(name)?.value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function defaultCheckoutToken(): Promise<string | undefined> {
|
|
99
|
+
return cookieValue(PREVIEW_COOKIE);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function defaultSessionToken(): Promise<string | undefined> {
|
|
103
|
+
return cookieValue(PREVIEW_SESSION_COOKIE);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createRenderPreview(
|
|
107
|
+
cms: CmsLike,
|
|
108
|
+
options: Omit<LayoutDocOptions, "models"> = {},
|
|
109
|
+
deps: RenderPreviewDeps = {},
|
|
110
|
+
) {
|
|
111
|
+
const draftEnabled = deps.draftEnabled ?? defaultDraftEnabled;
|
|
112
|
+
const readCheckoutToken = deps.readCheckoutToken ?? defaultCheckoutToken;
|
|
113
|
+
const readSessionToken = deps.readSessionToken ?? defaultSessionToken;
|
|
114
|
+
const now = deps.now ?? Date.now;
|
|
115
|
+
const limit = deps.limitPerSecond ?? RENDER_PREVIEW_PER_SECOND;
|
|
116
|
+
const secret = cms.config.previewToken ?? "";
|
|
117
|
+
|
|
118
|
+
return async function renderPreview(
|
|
119
|
+
ref: RenderPreviewRef,
|
|
120
|
+
doc: RenderPreviewDoc,
|
|
121
|
+
checkoutToken?: string,
|
|
122
|
+
): Promise<RenderPreviewResult> {
|
|
123
|
+
if (!secret) return { ok: false, error: "unauthorized" };
|
|
124
|
+
if (!(await draftEnabled())) return { ok: false, error: "unauthorized" };
|
|
125
|
+
const token = (await readCheckoutToken()) || checkoutToken;
|
|
126
|
+
const checkout = await readPreviewCookie(secret, token);
|
|
127
|
+
if (!checkout?.tree || !ref.tree || checkout.tree !== ref.tree) {
|
|
128
|
+
return { ok: false, error: "unauthorized" };
|
|
129
|
+
}
|
|
130
|
+
if (checkout.branch && ref.branch && checkout.branch !== ref.branch) {
|
|
131
|
+
return { ok: false, error: "unauthorized" };
|
|
132
|
+
}
|
|
133
|
+
const sessionToken = await readSessionToken();
|
|
134
|
+
const session = await readPreviewSession(secret, sessionToken);
|
|
135
|
+
if (!session || session.tree !== ref.tree) return { ok: false, error: "unauthorized" };
|
|
136
|
+
if (cms.config.workspaceId && session.workspaceId !== cms.config.workspaceId) {
|
|
137
|
+
return { ok: false, error: "unauthorized" };
|
|
138
|
+
}
|
|
139
|
+
if (session.branch && ref.branch && session.branch !== ref.branch) {
|
|
140
|
+
return { ok: false, error: "unauthorized" };
|
|
141
|
+
}
|
|
142
|
+
const fresh =
|
|
143
|
+
deps.sessionFresh ??
|
|
144
|
+
(cms.config.apiUrl ? (value: string) => defaultSessionFresh(cms.config.apiUrl!, value) : null);
|
|
145
|
+
if (fresh && sessionToken && !(await fresh(sessionToken, session))) {
|
|
146
|
+
return { ok: false, error: "unauthorized" };
|
|
147
|
+
}
|
|
148
|
+
const key = `${checkout.branch ?? ""}:${checkout.tree}`;
|
|
149
|
+
if (!allowRender(key, now(), limit)) return { ok: false, error: "rate_limited" };
|
|
150
|
+
try {
|
|
151
|
+
const { apiUrl, workspaceId } = cms.config;
|
|
152
|
+
const content =
|
|
153
|
+
apiUrl && workspaceId
|
|
154
|
+
? previewContentForCheckout({ apiUrl, workspaceId, previewToken: secret }, checkout)
|
|
155
|
+
: null;
|
|
156
|
+
// The editor's draft is the document; the outside record still comes from the source,
|
|
157
|
+
// so prices and images stay live while blocks change.
|
|
158
|
+
const model = cms.models.find((m) => m.type === ref.type);
|
|
159
|
+
let data: unknown;
|
|
160
|
+
if (model) {
|
|
161
|
+
const resolved = await resolveSourceData(requireSource(options.sources, model), model, ref);
|
|
162
|
+
if (!resolved.ok) return { ok: false, error: "record_not_found" };
|
|
163
|
+
data = resolved.data;
|
|
164
|
+
}
|
|
165
|
+
const node = await runWithPreviewContent(content, () =>
|
|
166
|
+
renderLayoutDoc(
|
|
167
|
+
{
|
|
168
|
+
locale: ref.locale,
|
|
169
|
+
type: ref.type,
|
|
170
|
+
slug: ref.slug,
|
|
171
|
+
source: "tree",
|
|
172
|
+
frontmatter: doc.frontmatter,
|
|
173
|
+
body: doc.body,
|
|
174
|
+
raw: doc.body,
|
|
175
|
+
},
|
|
176
|
+
{ ...options, models: cms.models },
|
|
177
|
+
data,
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
return { ok: true, node };
|
|
181
|
+
} catch (err) {
|
|
182
|
+
const message = err instanceof Error ? err.message : "Preview render failed";
|
|
183
|
+
return { ok: false, error: message };
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// packages/next/src/request-url.ts
|
|
2
|
+
|
|
3
|
+
/** Absolute URL of the current request, from forwarded headers when behind a proxy. */
|
|
4
|
+
export function requestUrlFrom(
|
|
5
|
+
get: (name: string) => string | null,
|
|
6
|
+
pathWithSearch: string,
|
|
7
|
+
): string {
|
|
8
|
+
const host = get("x-forwarded-host") ?? get("host") ?? "localhost:3000";
|
|
9
|
+
const proto =
|
|
10
|
+
get("x-forwarded-proto") ??
|
|
11
|
+
(host.startsWith("localhost") || host.startsWith("127.0.0.1") ? "http" : "https");
|
|
12
|
+
return `${proto}://${host}${pathWithSearch}`;
|
|
13
|
+
}
|
package/src/revalidate.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
import { timingSafeEqual } from "node:crypto";
|
|
5
5
|
import {
|
|
6
6
|
CONTENT_COMMITTED_EVENT,
|
|
7
|
-
BACKMATTER_CACHE_TAG,
|
|
8
7
|
pathsForCommittedDocs,
|
|
8
|
+
revalidateTagsForCommit,
|
|
9
9
|
type ContentCommittedEvent,
|
|
10
10
|
} from "@backmatter/core";
|
|
11
11
|
import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
|
|
@@ -57,7 +57,7 @@ export function createRevalidateRoute(
|
|
|
57
57
|
const { revalidatePath, revalidateTag } = cache ?? (await loadNextCache());
|
|
58
58
|
const paths = pathsForCommittedDocs(cms.models, body.docs ?? []);
|
|
59
59
|
for (const path of paths) revalidatePath(path);
|
|
60
|
-
revalidateTag(
|
|
60
|
+
for (const tag of revalidateTagsForCommit(body)) revalidateTag(tag, { expire: 0 });
|
|
61
61
|
if (body.full) revalidatePath("/", "layout");
|
|
62
62
|
return Response.json({ ok: true, paths });
|
|
63
63
|
};
|