@decocms/nextjs 7.5.3 → 7.6.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 CHANGED
@@ -78,7 +78,7 @@ import { ensureSetup } from "../../../deco/setup";
78
78
 
79
79
  export const dynamic = "force-dynamic";
80
80
 
81
- export const { GET, POST } = createDecoRouteHandlers({ setup: ensureSetup });
81
+ export const { GET, POST, OPTIONS } = createDecoRouteHandlers({ setup: ensureSetup });
82
82
  ```
83
83
 
84
84
  `dynamic = "force-dynamic"` is required — this route must never be
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/nextjs",
3
- "version": "7.5.3",
3
+ "version": "7.6.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for Next.js App Router",
6
6
  "repository": {
@@ -25,8 +25,8 @@
25
25
  "lint:unused": "knip"
26
26
  },
27
27
  "dependencies": {
28
- "@decocms/blocks": "7.5.3",
29
- "@decocms/blocks-admin": "7.5.3"
28
+ "@decocms/blocks": "7.6.0",
29
+ "@decocms/blocks-admin": "7.6.0"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "next": ">=15.0.0",
@@ -17,6 +17,14 @@ const mocks = vi.hoisted(() => ({
17
17
  handleMeta: vi.fn(() => new Response("meta")),
18
18
  handleRender: vi.fn(async (req: Request) => new Response(new URL(req.url).pathname)),
19
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
+ })),
20
28
  }));
21
29
  vi.mock("@decocms/blocks-admin", () => mocks);
22
30
 
@@ -151,3 +159,53 @@ describe("createDecoRouteHandlers", () => {
151
159
  expect(mocks.handleMeta).not.toHaveBeenCalled();
152
160
  });
153
161
  });
162
+
163
+ describe("createDecoRouteHandlers — CORS (Studio is a cross-origin browser client)", () => {
164
+ beforeEach(() => vi.clearAllMocks());
165
+
166
+ it("answers OPTIONS preflight with 204 + CORS headers, without running setup", async () => {
167
+ const setup = vi.fn(async () => {});
168
+ const { OPTIONS } = createDecoRouteHandlers({ setup });
169
+ const res = await OPTIONS(
170
+ new Request("http://x/live/_meta", {
171
+ method: "OPTIONS",
172
+ headers: {
173
+ origin: "https://decocms.com",
174
+ "access-control-request-method": "GET",
175
+ "access-control-request-headers": "if-none-match",
176
+ },
177
+ }),
178
+ );
179
+ expect(res.status).toBe(204);
180
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("https://decocms.com");
181
+ expect(res.headers.get("Access-Control-Allow-Headers")).toContain("If-None-Match");
182
+ expect(setup).not.toHaveBeenCalled();
183
+ });
184
+
185
+ it("stamps CORS headers on successful responses (meta via pre-rewrite URL)", async () => {
186
+ const { GET } = createDecoRouteHandlers();
187
+ const res = await GET(
188
+ new Request("http://x/live/_meta", { headers: { origin: "https://admin.deco.cx" } }),
189
+ );
190
+ expect(await res.text()).toBe("meta");
191
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("https://admin.deco.cx");
192
+ });
193
+
194
+ it("stamps CORS headers on method-gate 405s too", async () => {
195
+ const { GET } = createDecoRouteHandlers();
196
+ const res = await GET(
197
+ new Request("http://x/deco/invoke/site/actions/x", {
198
+ headers: { origin: "https://decocms.com" },
199
+ }),
200
+ );
201
+ expect(res.status).toBe(405);
202
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("https://decocms.com");
203
+ });
204
+
205
+ it("named exports carry CORS as well (metaGET)", async () => {
206
+ const res = await metaGET(
207
+ new Request("http://x/live/_meta", { headers: { origin: "https://decocms.com" } }),
208
+ );
209
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("https://decocms.com");
210
+ });
211
+ });
@@ -16,6 +16,7 @@
16
16
  * component code.
17
17
  */
18
18
  import {
19
+ corsHeaders,
19
20
  handleDecofileRead,
20
21
  handleDecofileReload,
21
22
  handleInvoke,
@@ -23,34 +24,65 @@ import {
23
24
  handleRender,
24
25
  } from "@decocms/blocks-admin";
25
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
+
26
58
  /** For app/live/_meta/route.ts: `export { metaGET as GET } from "@decocms/nextjs/routeHandlers"` */
27
59
  export async function metaGET(request: Request): Promise<Response> {
28
- return handleMeta(request);
60
+ return withCors(request, handleMeta(request));
29
61
  }
30
62
 
31
63
  /** For app/.decofile/route.ts (or an equivalent rewritten path — Next.js route
32
64
  * segments can't literally start with a dot; see Task 9's fixture for the
33
65
  * rewrite-rule workaround). */
34
- export async function decofileGET(): Promise<Response> {
35
- return handleDecofileRead();
66
+ export async function decofileGET(request: Request): Promise<Response> {
67
+ return withCors(request, await handleDecofileRead());
36
68
  }
37
69
 
38
70
  export async function decofilePOST(request: Request): Promise<Response> {
39
- return handleDecofileReload(request);
71
+ return withCors(request, await handleDecofileReload(request));
40
72
  }
41
73
 
42
74
  /** For app/deco/invoke/[...key]/route.ts */
43
75
  export async function invokePOST(request: Request): Promise<Response> {
44
- return handleInvoke(request);
76
+ return withCors(request, await handleInvoke(request));
45
77
  }
46
78
 
47
79
  /** For app/live/previews/[...path]/route.ts */
48
80
  export async function renderGET(request: Request): Promise<Response> {
49
- return handleRender(request);
81
+ return withCors(request, await handleRender(request));
50
82
  }
51
83
 
52
84
  export async function renderPOST(request: Request): Promise<Response> {
53
- return handleRender(request);
85
+ return withCors(request, await handleRender(request));
54
86
  }
55
87
 
56
88
  export interface DecoRouteHandlersOptions {
@@ -72,7 +104,7 @@ export interface DecoRouteHandlersOptions {
72
104
  * import { createDecoRouteHandlers } from "@decocms/nextjs/routeHandlers";
73
105
  * import { ensureSetup } from "../../../deco/setup";
74
106
  * export const dynamic = "force-dynamic";
75
- * export const { GET, POST } = createDecoRouteHandlers({ setup: ensureSetup });
107
+ * export const { GET, POST, OPTIONS } = createDecoRouteHandlers({ setup: ensureSetup });
76
108
  * ```
77
109
  *
78
110
  * `resolveAction` accepts BOTH the rewrite's public source path (e.g.
@@ -104,8 +136,14 @@ function resolveAction(pathname: string): string {
104
136
  export function createDecoRouteHandlers(options: DecoRouteHandlersOptions = {}): {
105
137
  GET(request: Request): Promise<Response>;
106
138
  POST(request: Request): Promise<Response>;
139
+ OPTIONS(request: Request): Promise<Response>;
107
140
  } {
108
141
  async function dispatch(request: Request): Promise<Response> {
142
+ // CORS preflight: Studio fetches cross-origin and its ETag flow sends
143
+ // If-None-Match (non-simple header), so browsers preflight every meta
144
+ // request. Answer before setup — preflights carry no work and must not
145
+ // pay (or fail on) the site bootstrap.
146
+ if (request.method === "OPTIONS") return preflight(request);
109
147
  await options.setup?.();
110
148
 
111
149
  const url = new URL(request.url);
@@ -170,5 +208,9 @@ export function createDecoRouteHandlers(options: DecoRouteHandlersOptions = {}):
170
208
  });
171
209
  }
172
210
 
173
- return { GET: dispatch, POST: dispatch };
211
+ async function dispatchWithCors(request: Request): Promise<Response> {
212
+ return withCors(request, await dispatch(request));
213
+ }
214
+
215
+ return { GET: dispatchWithCors, POST: dispatchWithCors, OPTIONS: dispatchWithCors };
174
216
  }