@decocms/nextjs 8.1.0-next.0 → 8.5.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/README.md +97 -328
- package/dist/DraftPreviewIndicator.d.ts +7 -0
- package/dist/DraftPreviewIndicator.d.ts.map +1 -0
- package/dist/DraftPreviewIndicator.js +19 -0
- package/dist/DraftPreviewIndicator.js.map +1 -0
- package/dist/StudioBridge.d.ts +16 -0
- package/dist/StudioBridge.d.ts.map +1 -0
- package/dist/StudioBridge.js +17 -0
- package/dist/StudioBridge.js.map +1 -0
- package/dist/draft.d.ts +37 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +45 -0
- package/dist/draft.js.map +1 -0
- package/dist/drafts.d.ts +94 -0
- package/dist/drafts.d.ts.map +1 -0
- package/dist/drafts.js +210 -0
- package/dist/drafts.js.map +1 -0
- package/dist/image-loader/index.d.ts +36 -0
- package/dist/image-loader/index.d.ts.map +1 -0
- package/dist/image-loader/index.js +27 -0
- package/dist/image-loader/index.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/instrumentation/index.d.ts +74 -0
- package/dist/instrumentation/index.d.ts.map +1 -0
- package/dist/instrumentation/index.js +294 -0
- package/dist/instrumentation/index.js.map +1 -0
- package/dist/proxy/index.d.ts +82 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +91 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/server/index.d.ts +46 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +64 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/slot.d.ts +6 -0
- package/dist/server/slot.d.ts.map +1 -0
- package/dist/server/slot.js +13 -0
- package/dist/server/slot.js.map +1 -0
- package/dist/studio.d.ts +30 -0
- package/dist/studio.d.ts.map +1 -0
- package/dist/studio.js +55 -0
- package/dist/studio.js.map +1 -0
- package/package.json +53 -22
- package/src/DraftPreviewIndicator.tsx +17 -16
- package/src/StudioBridge.ts +23 -0
- package/src/config/index.cjs +78 -0
- package/src/config/index.d.cts +27 -0
- package/src/draft.ts +32 -215
- package/src/drafts.ts +229 -0
- package/src/image-loader/index.ts +62 -0
- package/src/index.ts +31 -45
- package/src/instrumentation/index.ts +337 -0
- package/src/proxy/index.ts +164 -0
- package/src/server/index.ts +77 -0
- package/src/server/slot.ts +14 -0
- package/src/studio.ts +54 -0
- package/src/ClientOnlySection.tsx +0 -20
- package/src/DecoPageRenderer.test.tsx +0 -37
- package/src/DecoPageRenderer.tsx +0 -107
- package/src/DecoRootLayout.test.tsx +0 -22
- package/src/DecoRootLayout.tsx +0 -82
- package/src/DeferredSection.test.tsx +0 -27
- package/src/DeferredSection.tsx +0 -59
- package/src/DraftPreviewBadge.tsx +0 -16
- package/src/SectionRenderer.test.tsx +0 -97
- package/src/SectionRenderer.tsx +0 -58
- package/src/__conformance__/observability.test.ts +0 -66
- package/src/config.cjs +0 -92
- package/src/config.d.cts +0 -4
- package/src/config.test.ts +0 -86
- package/src/createDecoPage.draft.test.tsx +0 -91
- package/src/createDecoPage.test.tsx +0 -63
- package/src/createDecoPage.tsx +0 -173
- package/src/createDecoPreviewPage.test.tsx +0 -71
- package/src/createDecoPreviewPage.tsx +0 -108
- package/src/draft.test.ts +0 -73
- package/src/draftConstants.test.ts +0 -21
- package/src/draftConstants.ts +0 -30
- package/src/draftMiddleware.test.ts +0 -179
- package/src/draftMiddleware.ts +0 -163
- package/src/draftShell.test.ts +0 -76
- package/src/routeHandlers.test.ts +0 -243
- package/src/routeHandlers.ts +0 -227
- package/src/setup.test.ts +0 -253
- package/src/setup.ts +0 -180
- package/tsconfig.json +0 -7
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
// @vitest-environment node
|
|
2
|
-
//
|
|
3
|
-
// Route Handlers run server-side with no DOM (same rationale as
|
|
4
|
-
// setup.test.ts in this package). This matters concretely here: jsdom's
|
|
5
|
-
// bundled Request polyfill does not reproduce Node/edge's Request-as-init
|
|
6
|
-
// body-stream forwarding (`new Request(url, existingRequest)`) — under
|
|
7
|
-
// jsdom the body-forwarding test below silently loses the body. Node's
|
|
8
|
-
// native Request (used in this environment, and in the real Next.js
|
|
9
|
-
// runtime) preserves it, which is what the previews-rebuild code path
|
|
10
|
-
// relies on.
|
|
11
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
|
-
|
|
13
|
-
const mocks = vi.hoisted(() => ({
|
|
14
|
-
handleDecofileRead: vi.fn(async () => new Response("decofile")),
|
|
15
|
-
handleDecofileReload: vi.fn(async () => new Response("reloaded")),
|
|
16
|
-
handleInvoke: vi.fn(async () => new Response("invoked")),
|
|
17
|
-
handleMeta: vi.fn(() => new Response("meta")),
|
|
18
|
-
handleRender: vi.fn(async (req: Request) => new Response(new URL(req.url).pathname)),
|
|
19
|
-
setMetaData: vi.fn(),
|
|
20
|
-
// Real corsHeaders shape (mirrors blocks-admin/src/admin/cors.ts) so the
|
|
21
|
-
// CORS assertions below test genuine header propagation, not a stub echo.
|
|
22
|
-
corsHeaders: vi.fn((req: Request) => ({
|
|
23
|
-
"Access-Control-Allow-Origin": req.headers.get("origin") || "*",
|
|
24
|
-
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
25
|
-
"Access-Control-Allow-Headers": "Content-Type, Authorization, If-None-Match",
|
|
26
|
-
"Access-Control-Allow-Credentials": "true",
|
|
27
|
-
})),
|
|
28
|
-
}));
|
|
29
|
-
vi.mock("@decocms/blocks-admin", () => mocks);
|
|
30
|
-
|
|
31
|
-
import { setMetaData } from "@decocms/blocks-admin";
|
|
32
|
-
import { createDecoRouteHandlers, metaGET } from "./routeHandlers";
|
|
33
|
-
|
|
34
|
-
describe("routeHandlers (next)", () => {
|
|
35
|
-
it("metaGET returns the schema response", async () => {
|
|
36
|
-
setMetaData({ sections: {}, actions: {}, loaders: {} } as any);
|
|
37
|
-
const response = await metaGET(new Request("https://example.com/live/_meta"));
|
|
38
|
-
expect(response.status).toBe(200);
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe("createDecoRouteHandlers", () => {
|
|
43
|
-
beforeEach(() => vi.clearAllMocks());
|
|
44
|
-
|
|
45
|
-
it("runs setup before dispatching and routes decofile GET/POST", async () => {
|
|
46
|
-
const order: string[] = [];
|
|
47
|
-
const setup = vi.fn(async () => {
|
|
48
|
-
order.push("setup");
|
|
49
|
-
});
|
|
50
|
-
const { GET, POST } = createDecoRouteHandlers({ setup });
|
|
51
|
-
|
|
52
|
-
await GET(new Request("http://x/deco/decofile"));
|
|
53
|
-
expect(setup).toHaveBeenCalled();
|
|
54
|
-
expect(mocks.handleDecofileRead).toHaveBeenCalled();
|
|
55
|
-
|
|
56
|
-
await POST(new Request("http://x/deco/decofile", { method: "POST" }));
|
|
57
|
-
expect(mocks.handleDecofileReload).toHaveBeenCalled();
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("routes meta, render, and invoke", async () => {
|
|
61
|
-
const { GET, POST } = createDecoRouteHandlers();
|
|
62
|
-
await GET(new Request("http://x/deco/meta"));
|
|
63
|
-
expect(mocks.handleMeta).toHaveBeenCalled();
|
|
64
|
-
await POST(new Request("http://x/deco/render", { method: "POST" }));
|
|
65
|
-
expect(mocks.handleRender).toHaveBeenCalled();
|
|
66
|
-
await POST(new Request("http://x/deco/invoke/site/actions/x", { method: "POST" }));
|
|
67
|
-
expect(mocks.handleInvoke).toHaveBeenCalled();
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("redirects preview GETs to the fixed RSC page and preserves the path and query", async () => {
|
|
71
|
-
const { GET } = createDecoRouteHandlers();
|
|
72
|
-
const res = await GET(
|
|
73
|
-
new Request("http://x/deco/previews/pages-Home-123?props=x&deviceHint=mobile"),
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
expect(res.status).toBe(307);
|
|
77
|
-
expect(res.headers.get("location")).toBe(
|
|
78
|
-
"http://x/deco/preview/pages-Home-123?props=x&deviceHint=mobile",
|
|
79
|
-
);
|
|
80
|
-
expect(mocks.handleRender).not.toHaveBeenCalled();
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("redirects the public /live/previews path to the fixed RSC page", async () => {
|
|
84
|
-
const { GET } = createDecoRouteHandlers();
|
|
85
|
-
const res = await GET(
|
|
86
|
-
new Request("http://x/live/previews/site/sections/Hero.tsx?deviceHint=desktop"),
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
expect(res.status).toBe(307);
|
|
90
|
-
expect(res.headers.get("location")).toBe(
|
|
91
|
-
"http://x/deco/preview/site/sections/Hero.tsx?deviceHint=desktop",
|
|
92
|
-
);
|
|
93
|
-
expect(mocks.handleRender).not.toHaveBeenCalled();
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("keeps preview POSTs on handleRender", async () => {
|
|
97
|
-
const { POST } = createDecoRouteHandlers();
|
|
98
|
-
const res = await POST(
|
|
99
|
-
new Request("http://x/deco/previews/pages-Home-123", {
|
|
100
|
-
method: "POST",
|
|
101
|
-
headers: { "Content-Type": "application/json" },
|
|
102
|
-
body: JSON.stringify({ hello: "world" }),
|
|
103
|
-
}),
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
expect(res.status).toBe(200);
|
|
107
|
-
expect(mocks.handleRender).toHaveBeenCalledOnce();
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("forwards a POST body through the /deco/previews/* URL rebuild (Request-as-init carries the body stream)", async () => {
|
|
111
|
-
// Pins the `new Request(rebuilt, request)` semantics documented on the
|
|
112
|
-
// rebuild line: passing a Request as `init` clones the body stream
|
|
113
|
-
// without a `duplex` option. A refactor to a plain init object would
|
|
114
|
-
// throw "duplex option is required" for this POST body.
|
|
115
|
-
mocks.handleRender.mockImplementationOnce(async (req: Request) => {
|
|
116
|
-
const body = await req.json();
|
|
117
|
-
return new Response(JSON.stringify({ pathname: new URL(req.url).pathname, body }));
|
|
118
|
-
});
|
|
119
|
-
const { POST } = createDecoRouteHandlers();
|
|
120
|
-
const res = await POST(
|
|
121
|
-
new Request("http://x/deco/previews/pages-Home-123", {
|
|
122
|
-
method: "POST",
|
|
123
|
-
headers: { "Content-Type": "application/json" },
|
|
124
|
-
body: JSON.stringify({ hello: "world" }),
|
|
125
|
-
}),
|
|
126
|
-
);
|
|
127
|
-
const json = await res.json();
|
|
128
|
-
expect(json.pathname).toBe("/live/previews/pages-Home-123");
|
|
129
|
-
expect(json.body).toEqual({ hello: "world" });
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("404s unknown deco paths", async () => {
|
|
133
|
-
const { GET } = createDecoRouteHandlers();
|
|
134
|
-
const res = await GET(new Request("http://x/deco/nope"));
|
|
135
|
-
expect(res.status).toBe(404);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
// Regression coverage for the rewrite-source paths, not just their
|
|
139
|
-
// /deco/* destinations: a Next.js App Router route handler reached via a
|
|
140
|
-
// next.config.js `rewrites()` entry sees `request.url` as the ORIGINAL,
|
|
141
|
-
// pre-rewrite path (verified empirically against a real `next build` +
|
|
142
|
-
// `next start`) — NOT the /deco/* destination the rewrite maps to. Every
|
|
143
|
-
// test above this one only ever constructs an already-/deco/*-shaped
|
|
144
|
-
// Request, so it would keep passing even if the dispatcher only matched
|
|
145
|
-
// that form and 404'd every real rewritten request — which is exactly
|
|
146
|
-
// the bug these tests catch.
|
|
147
|
-
it("routes the rewrite-source /.decofile path (not just its /deco/decofile destination)", async () => {
|
|
148
|
-
const { GET, POST } = createDecoRouteHandlers();
|
|
149
|
-
await GET(new Request("http://x/.decofile"));
|
|
150
|
-
expect(mocks.handleDecofileRead).toHaveBeenCalled();
|
|
151
|
-
await POST(new Request("http://x/.decofile", { method: "POST" }));
|
|
152
|
-
expect(mocks.handleDecofileReload).toHaveBeenCalled();
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("routes the rewrite-source /live/_meta path (not just its /deco/meta destination)", async () => {
|
|
156
|
-
const { GET } = createDecoRouteHandlers();
|
|
157
|
-
const res = await GET(new Request("http://x/live/_meta"));
|
|
158
|
-
expect(mocks.handleMeta).toHaveBeenCalled();
|
|
159
|
-
expect(res.status).not.toBe(404);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("405s POST /live/_meta with Allow: GET (the PRE-rewrite URL form)", async () => {
|
|
163
|
-
const { POST } = createDecoRouteHandlers();
|
|
164
|
-
const res = await POST(new Request("http://x/live/_meta", { method: "POST" }));
|
|
165
|
-
expect(res.status).toBe(405);
|
|
166
|
-
expect(res.headers.get("Allow")).toBe("GET");
|
|
167
|
-
expect(mocks.handleMeta).not.toHaveBeenCalled();
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it("redirects the rewrite-source /live/previews/* path to the fixed RSC page", async () => {
|
|
171
|
-
const { GET } = createDecoRouteHandlers();
|
|
172
|
-
const res = await GET(new Request("http://x/live/previews/pages-Home-123?props=x"));
|
|
173
|
-
expect(res.status).toBe(307);
|
|
174
|
-
expect(res.headers.get("location")).toBe("http://x/deco/preview/pages-Home-123?props=x");
|
|
175
|
-
expect(mocks.handleRender).not.toHaveBeenCalled();
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("405s GET /deco/invoke/* with Allow: POST (CSRF protection — see comment on the invoke branch)", async () => {
|
|
179
|
-
const { GET } = createDecoRouteHandlers();
|
|
180
|
-
const res = await GET(new Request("http://x/deco/invoke/site/actions/x"));
|
|
181
|
-
expect(res.status).toBe(405);
|
|
182
|
-
expect(res.headers.get("Allow")).toBe("POST");
|
|
183
|
-
expect(mocks.handleInvoke).not.toHaveBeenCalled();
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it("405s POST /deco/meta with Allow: GET", async () => {
|
|
187
|
-
const { POST } = createDecoRouteHandlers();
|
|
188
|
-
const res = await POST(new Request("http://x/deco/meta", { method: "POST" }));
|
|
189
|
-
expect(res.status).toBe(405);
|
|
190
|
-
expect(res.headers.get("Allow")).toBe("GET");
|
|
191
|
-
expect(mocks.handleMeta).not.toHaveBeenCalled();
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
describe("createDecoRouteHandlers — CORS (Studio is a cross-origin browser client)", () => {
|
|
196
|
-
beforeEach(() => vi.clearAllMocks());
|
|
197
|
-
|
|
198
|
-
it("answers OPTIONS preflight with 204 + CORS headers, without running setup", async () => {
|
|
199
|
-
const setup = vi.fn(async () => {});
|
|
200
|
-
const { OPTIONS } = createDecoRouteHandlers({ setup });
|
|
201
|
-
const res = await OPTIONS(
|
|
202
|
-
new Request("http://x/live/_meta", {
|
|
203
|
-
method: "OPTIONS",
|
|
204
|
-
headers: {
|
|
205
|
-
origin: "https://decocms.com",
|
|
206
|
-
"access-control-request-method": "GET",
|
|
207
|
-
"access-control-request-headers": "if-none-match",
|
|
208
|
-
},
|
|
209
|
-
}),
|
|
210
|
-
);
|
|
211
|
-
expect(res.status).toBe(204);
|
|
212
|
-
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("https://decocms.com");
|
|
213
|
-
expect(res.headers.get("Access-Control-Allow-Headers")).toContain("If-None-Match");
|
|
214
|
-
expect(setup).not.toHaveBeenCalled();
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it("stamps CORS headers on successful responses (meta via pre-rewrite URL)", async () => {
|
|
218
|
-
const { GET } = createDecoRouteHandlers();
|
|
219
|
-
const res = await GET(
|
|
220
|
-
new Request("http://x/live/_meta", { headers: { origin: "https://admin.deco.cx" } }),
|
|
221
|
-
);
|
|
222
|
-
expect(await res.text()).toBe("meta");
|
|
223
|
-
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("https://admin.deco.cx");
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
it("stamps CORS headers on method-gate 405s too", async () => {
|
|
227
|
-
const { GET } = createDecoRouteHandlers();
|
|
228
|
-
const res = await GET(
|
|
229
|
-
new Request("http://x/deco/invoke/site/actions/x", {
|
|
230
|
-
headers: { origin: "https://decocms.com" },
|
|
231
|
-
}),
|
|
232
|
-
);
|
|
233
|
-
expect(res.status).toBe(405);
|
|
234
|
-
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("https://decocms.com");
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it("named exports carry CORS as well (metaGET)", async () => {
|
|
238
|
-
const res = await metaGET(
|
|
239
|
-
new Request("http://x/live/_meta", { headers: { origin: "https://decocms.com" } }),
|
|
240
|
-
);
|
|
241
|
-
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("https://decocms.com");
|
|
242
|
-
});
|
|
243
|
-
});
|
package/src/routeHandlers.ts
DELETED
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Admin Route Handlers for Next.js App Router.
|
|
3
|
-
*
|
|
4
|
-
* IMPORT THESE FROM `@decocms/nextjs/routeHandlers` IN ROUTE FILES — never
|
|
5
|
-
* from the `@decocms/nextjs` root barrel. Route handlers (app router
|
|
6
|
-
* route.ts files) evaluate their whole module graph against React's
|
|
7
|
-
* react-server build and IGNORE `"use client"` directives (there is no
|
|
8
|
-
* client graph to move a module into). The root barrel also exports the
|
|
9
|
-
* render components (SectionRenderer, DecoRootLayout, ...), whose graph
|
|
10
|
-
* reaches `@decocms/blocks/hooks` — files with module-scope client-React
|
|
11
|
-
* usage (e.g. `class ... extends Component`) that the react-server build
|
|
12
|
-
* does not export. Importing the root barrel from a route file therefore
|
|
13
|
-
* crashes at module evaluation ("...createContext is not a function" /
|
|
14
|
-
* "Class extends value undefined is not a constructor") before the handler
|
|
15
|
-
* ever runs. This subpath keeps the route-handler graph free of all
|
|
16
|
-
* component code.
|
|
17
|
-
*/
|
|
18
|
-
import {
|
|
19
|
-
corsHeaders,
|
|
20
|
-
handleDecofileRead,
|
|
21
|
-
handleDecofileReload,
|
|
22
|
-
handleInvoke,
|
|
23
|
-
handleMeta,
|
|
24
|
-
handleRender,
|
|
25
|
-
} from "@decocms/blocks-admin";
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Stamp the admin CORS headers onto a handler response. The Studio is a
|
|
29
|
-
* cross-origin browser client (decocms.com / admin.deco.cx fetching the
|
|
30
|
-
* site's /live/_meta, /.decofile, ...), and its ETag flow sends
|
|
31
|
-
* `If-None-Match` — a non-simple header, so requests preflight. On
|
|
32
|
-
* TanStack this stamping lives in workerEntry/adminRoutes; on Next.js it
|
|
33
|
-
* lives HERE, since these handlers/dispatcher are the only admin surface.
|
|
34
|
-
* Response headers can be immutable (e.g. a cached/replayed body) — fall
|
|
35
|
-
* back to cloning.
|
|
36
|
-
*/
|
|
37
|
-
function withCors(request: Request, response: Response): Response {
|
|
38
|
-
const cors = corsHeaders(request);
|
|
39
|
-
try {
|
|
40
|
-
for (const [k, v] of Object.entries(cors)) response.headers.set(k, v);
|
|
41
|
-
return response;
|
|
42
|
-
} catch {
|
|
43
|
-
const headers = new Headers(response.headers);
|
|
44
|
-
for (const [k, v] of Object.entries(cors)) headers.set(k, v);
|
|
45
|
-
return new Response(response.body, {
|
|
46
|
-
status: response.status,
|
|
47
|
-
statusText: response.statusText,
|
|
48
|
-
headers,
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Answer a CORS preflight for the admin protocol. */
|
|
54
|
-
function preflight(request: Request): Response {
|
|
55
|
-
return new Response(null, { status: 204, headers: corsHeaders(request) });
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** For app/live/_meta/route.ts: `export { metaGET as GET } from "@decocms/nextjs/routeHandlers"` */
|
|
59
|
-
export async function metaGET(request: Request): Promise<Response> {
|
|
60
|
-
return withCors(request, await handleMeta(request));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** For app/.decofile/route.ts (or an equivalent rewritten path — Next.js route
|
|
64
|
-
* segments can't literally start with a dot; see Task 9's fixture for the
|
|
65
|
-
* rewrite-rule workaround). */
|
|
66
|
-
export async function decofileGET(request: Request): Promise<Response> {
|
|
67
|
-
return withCors(request, await handleDecofileRead());
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export async function decofilePOST(request: Request): Promise<Response> {
|
|
71
|
-
return withCors(request, await handleDecofileReload(request));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** For app/deco/invoke/[...key]/route.ts */
|
|
75
|
-
export async function invokePOST(request: Request): Promise<Response> {
|
|
76
|
-
return withCors(request, await handleInvoke(request));
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** For app/live/previews/[...path]/route.ts */
|
|
80
|
-
export async function renderGET(request: Request): Promise<Response> {
|
|
81
|
-
return withCors(request, await handleRender(request));
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export async function renderPOST(request: Request): Promise<Response> {
|
|
85
|
-
return withCors(request, await handleRender(request));
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export interface DecoRouteHandlersOptions {
|
|
89
|
-
/**
|
|
90
|
-
* Site bootstrap, awaited before every admin request — pass the
|
|
91
|
-
* ensureSetup returned by createNextSetup (@decocms/nextjs/setup).
|
|
92
|
-
*/
|
|
93
|
-
setup?: () => Promise<void>;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const PREVIEW_PAGE_PATH = "/deco/preview";
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Single catch-all dispatcher for the whole Studio admin protocol. Mount
|
|
100
|
-
* at `app/deco/[[...deco]]/route.ts` and wrap next.config with
|
|
101
|
-
* `withDeco()` (@decocms/nextjs/config), whose rewrites map the protocol
|
|
102
|
-
* URLs Next can't express as segments (`/.decofile`, `/live/_meta`,
|
|
103
|
-
* `/live/previews/*`) into `/deco/*`:
|
|
104
|
-
*
|
|
105
|
-
* ```ts
|
|
106
|
-
* import { createDecoRouteHandlers } from "@decocms/nextjs/routeHandlers";
|
|
107
|
-
* import { ensureSetup } from "../../../deco/setup";
|
|
108
|
-
* export const dynamic = "force-dynamic";
|
|
109
|
-
* export const { GET, POST, OPTIONS } = createDecoRouteHandlers({
|
|
110
|
-
* setup: ensureSetup,
|
|
111
|
-
* });
|
|
112
|
-
* ```
|
|
113
|
-
*
|
|
114
|
-
* `resolveAction` accepts BOTH the rewrite's public source path (e.g.
|
|
115
|
-
* `/.decofile`, `/live/_meta`, `/live/previews/*`) AND the rewrite's own
|
|
116
|
-
* destination path (`/deco/*`) — not just the latter. This is load-bearing:
|
|
117
|
-
* verified empirically against a real `next build && next start` (both
|
|
118
|
-
* dev and production servers) that a Next.js App Router route handler
|
|
119
|
-
* reached via a `next.config.js`-level `rewrites()` entry sees
|
|
120
|
-
* `request.url` (and `NextRequest.nextUrl.pathname`) as the ORIGINAL,
|
|
121
|
-
* pre-rewrite path the client requested — rewrites are transparent to the
|
|
122
|
-
* client and, in this respect, to the handler too. Only a *direct* request
|
|
123
|
-
* to `/deco/*` (bypassing the rewrite, e.g. `/deco/invoke/*`, which has no
|
|
124
|
-
* public alias) arrives with a `/deco/`-prefixed pathname. Matching only
|
|
125
|
-
* the `/deco/*` form (as an earlier version of this dispatcher did) makes
|
|
126
|
-
* every rewritten protocol URL 404 — the dispatcher never sees a
|
|
127
|
-
* `/deco/decofile`-shaped path in that case, it sees `/.decofile` verbatim.
|
|
128
|
-
*/
|
|
129
|
-
function resolveAction(pathname: string): string {
|
|
130
|
-
if (pathname.startsWith("/deco/")) return pathname.slice("/deco/".length);
|
|
131
|
-
if (pathname === "/.decofile") return "decofile";
|
|
132
|
-
if (pathname === "/live/_meta") return "meta";
|
|
133
|
-
if (pathname === "/live/previews") return "previews";
|
|
134
|
-
if (pathname.startsWith("/live/previews/")) {
|
|
135
|
-
return `previews/${pathname.slice("/live/previews/".length)}`;
|
|
136
|
-
}
|
|
137
|
-
return pathname;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export function createDecoRouteHandlers(options: DecoRouteHandlersOptions = {}): {
|
|
141
|
-
GET(request: Request): Promise<Response>;
|
|
142
|
-
POST(request: Request): Promise<Response>;
|
|
143
|
-
OPTIONS(request: Request): Promise<Response>;
|
|
144
|
-
} {
|
|
145
|
-
async function dispatch(request: Request): Promise<Response> {
|
|
146
|
-
// CORS preflight: Studio fetches cross-origin and its ETag flow sends
|
|
147
|
-
// If-None-Match (non-simple header), so browsers preflight every meta
|
|
148
|
-
// request. Answer before setup — preflights carry no work and must not
|
|
149
|
-
// pay (or fail on) the site bootstrap.
|
|
150
|
-
if (request.method === "OPTIONS") return preflight(request);
|
|
151
|
-
await options.setup?.();
|
|
152
|
-
|
|
153
|
-
const url = new URL(request.url);
|
|
154
|
-
const action = resolveAction(url.pathname);
|
|
155
|
-
|
|
156
|
-
if (action === "decofile") {
|
|
157
|
-
return request.method === "POST" ? handleDecofileReload(request) : handleDecofileRead();
|
|
158
|
-
}
|
|
159
|
-
if (action === "meta") {
|
|
160
|
-
// GET only: this is a read-only schema endpoint, no legitimate POST use.
|
|
161
|
-
if (request.method !== "GET") {
|
|
162
|
-
return new Response(JSON.stringify({ error: "Method not allowed: meta is GET-only" }), {
|
|
163
|
-
status: 405,
|
|
164
|
-
headers: { "Content-Type": "application/json", Allow: "GET" },
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
return handleMeta(request);
|
|
168
|
-
}
|
|
169
|
-
if (action === "render") return handleRender(request);
|
|
170
|
-
if (action.startsWith("invoke/")) {
|
|
171
|
-
// POST only: handleInvoke has no auth of its own and falls back to a
|
|
172
|
-
// `?props=<json>` query string for GET requests. A GET is a CORS
|
|
173
|
-
// "simple request" (no preflight), so an unauthenticated
|
|
174
|
-
// `<img src="https://site/deco/invoke/site/actions/...?props=...">`
|
|
175
|
-
// on a third-party page would be able to trigger mutating VTEX
|
|
176
|
-
// actions cross-site (CSRF). The per-route `invokePOST` export this
|
|
177
|
-
// dispatcher replaces was POST-only for exactly this reason — keep
|
|
178
|
-
// that restriction here.
|
|
179
|
-
if (request.method !== "POST") {
|
|
180
|
-
return new Response(
|
|
181
|
-
JSON.stringify({ error: "Method not allowed: invoke is POST-only (CSRF protection)" }),
|
|
182
|
-
{
|
|
183
|
-
status: 405,
|
|
184
|
-
headers: { "Content-Type": "application/json", Allow: "POST" },
|
|
185
|
-
},
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
return handleInvoke(request);
|
|
189
|
-
}
|
|
190
|
-
if (action === "previews" || action.startsWith("previews/")) {
|
|
191
|
-
if (request.method === "GET") {
|
|
192
|
-
const rest = action === "previews" ? "" : action.slice("previews/".length);
|
|
193
|
-
const target = new URL(url);
|
|
194
|
-
target.pathname = rest ? `${PREVIEW_PAGE_PATH}/${rest}` : PREVIEW_PAGE_PATH;
|
|
195
|
-
return Response.redirect(target, 307);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// handleRender parses the literal "/live/previews/" prefix.
|
|
199
|
-
// `resolveAction` above already normalizes BOTH a direct
|
|
200
|
-
// `/deco/previews/*` hit and a rewritten `/live/previews/*` hit down
|
|
201
|
-
// to this same `action` shape, so rebuilding unconditionally here is
|
|
202
|
-
// a no-op in the rewrite case (rebuilt === original) and the
|
|
203
|
-
// necessary reconstruction in the direct-hit case.
|
|
204
|
-
const rest = action === "previews" ? "" : action.slice("previews/".length);
|
|
205
|
-
const rebuilt = new URL(url);
|
|
206
|
-
rebuilt.pathname = `/live/previews/${rest}`;
|
|
207
|
-
// `request` (a Request) is passed as the `init` argument here, not a
|
|
208
|
-
// plain object — the Request-as-init form clones headers/method/body
|
|
209
|
-
// (including the body *stream*) without needing an explicit `duplex`
|
|
210
|
-
// option. Rewriting this as `new Request(rebuilt, { ...request })` or
|
|
211
|
-
// any plain-object init would throw "duplex option is required" for
|
|
212
|
-
// POST bodies with a body stream — see the test below that posts a
|
|
213
|
-
// JSON body through this branch and asserts it still parses.
|
|
214
|
-
return handleRender(new Request(rebuilt, request));
|
|
215
|
-
}
|
|
216
|
-
return new Response(JSON.stringify({ error: `Unknown deco route: ${url.pathname}` }), {
|
|
217
|
-
status: 404,
|
|
218
|
-
headers: { "Content-Type": "application/json" },
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
async function dispatchWithCors(request: Request): Promise<Response> {
|
|
223
|
-
return withCors(request, await dispatch(request));
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
return { GET: dispatchWithCors, POST: dispatchWithCors, OPTIONS: dispatchWithCors };
|
|
227
|
-
}
|