@decocms/nextjs 8.0.0 → 8.4.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.
Files changed (85) hide show
  1. package/README.md +77 -332
  2. package/dist/DraftPreviewIndicator.d.ts +7 -0
  3. package/dist/DraftPreviewIndicator.d.ts.map +1 -0
  4. package/dist/DraftPreviewIndicator.js +19 -0
  5. package/dist/DraftPreviewIndicator.js.map +1 -0
  6. package/dist/StudioBridge.d.ts +16 -0
  7. package/dist/StudioBridge.d.ts.map +1 -0
  8. package/dist/StudioBridge.js +17 -0
  9. package/dist/StudioBridge.js.map +1 -0
  10. package/dist/draft.d.ts +35 -0
  11. package/dist/draft.d.ts.map +1 -0
  12. package/dist/draft.js +43 -0
  13. package/dist/draft.js.map +1 -0
  14. package/dist/drafts.d.ts +94 -0
  15. package/dist/drafts.d.ts.map +1 -0
  16. package/dist/drafts.js +210 -0
  17. package/dist/drafts.js.map +1 -0
  18. package/dist/image-loader/index.d.ts +36 -0
  19. package/dist/image-loader/index.d.ts.map +1 -0
  20. package/dist/image-loader/index.js +27 -0
  21. package/dist/image-loader/index.js.map +1 -0
  22. package/dist/index.d.ts +29 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +29 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/proxy/index.d.ts +79 -0
  27. package/dist/proxy/index.d.ts.map +1 -0
  28. package/dist/proxy/index.js +92 -0
  29. package/dist/proxy/index.js.map +1 -0
  30. package/dist/server/index.d.ts +46 -0
  31. package/dist/server/index.d.ts.map +1 -0
  32. package/dist/server/index.js +64 -0
  33. package/dist/server/index.js.map +1 -0
  34. package/dist/server/slot.d.ts +6 -0
  35. package/dist/server/slot.d.ts.map +1 -0
  36. package/dist/server/slot.js +13 -0
  37. package/dist/server/slot.js.map +1 -0
  38. package/dist/studio.d.ts +30 -0
  39. package/dist/studio.d.ts.map +1 -0
  40. package/dist/studio.js +55 -0
  41. package/dist/studio.js.map +1 -0
  42. package/package.json +41 -22
  43. package/src/DraftPreviewIndicator.tsx +17 -16
  44. package/src/StudioBridge.ts +23 -0
  45. package/src/config/index.cjs +78 -0
  46. package/src/config/index.d.cts +27 -0
  47. package/src/draft.ts +30 -210
  48. package/src/drafts.ts +229 -0
  49. package/src/image-loader/index.ts +62 -0
  50. package/src/index.ts +31 -43
  51. package/src/proxy/index.ts +161 -0
  52. package/src/server/index.ts +77 -0
  53. package/src/server/slot.ts +14 -0
  54. package/src/studio.ts +54 -0
  55. package/src/ClientOnlySection.tsx +0 -20
  56. package/src/DecoPageRenderer.test.tsx +0 -37
  57. package/src/DecoPageRenderer.tsx +0 -107
  58. package/src/DecoRootLayout.test.tsx +0 -22
  59. package/src/DecoRootLayout.tsx +0 -82
  60. package/src/DeferredSection.test.tsx +0 -27
  61. package/src/DeferredSection.tsx +0 -59
  62. package/src/DraftPreviewBadge.render.test.tsx +0 -16
  63. package/src/DraftPreviewBadge.test.ts +0 -37
  64. package/src/DraftPreviewBadge.tsx +0 -252
  65. package/src/SectionRenderer.test.tsx +0 -97
  66. package/src/SectionRenderer.tsx +0 -58
  67. package/src/config.cjs +0 -92
  68. package/src/config.d.cts +0 -4
  69. package/src/config.test.ts +0 -86
  70. package/src/createDecoPage.draft.test.tsx +0 -91
  71. package/src/createDecoPage.test.tsx +0 -63
  72. package/src/createDecoPage.tsx +0 -173
  73. package/src/createDecoPreviewPage.test.tsx +0 -71
  74. package/src/createDecoPreviewPage.tsx +0 -108
  75. package/src/decoMark.ts +0 -11
  76. package/src/draft.test.ts +0 -73
  77. package/src/draftConstants.test.ts +0 -21
  78. package/src/draftConstants.ts +0 -15
  79. package/src/draftMiddleware.test.ts +0 -147
  80. package/src/draftMiddleware.ts +0 -136
  81. package/src/routeHandlers.test.ts +0 -243
  82. package/src/routeHandlers.ts +0 -227
  83. package/src/setup.test.ts +0 -253
  84. package/src/setup.ts +0 -180
  85. package/tsconfig.json +0 -7
@@ -1,21 +0,0 @@
1
- import { DRAFT_COOKIE_NAME, DRAFT_QUERY_PARAM } from "@decocms/blocks/cms";
2
- import { describe, expect, it } from "vitest";
3
-
4
- import { DRAFT_COOKIE, DRAFT_PARAM } from "./draftConstants";
5
-
6
- /**
7
- * The draft cookie/param names are wire protocol shared across a package
8
- * boundary: the client-safe copy here (the badge imports it without pulling
9
- * `next/headers` into the client bundle) MUST stay identical to the
10
- * framework-agnostic owner in `@decocms/blocks`, which `/deco/invoke` reads to
11
- * bind the same draft. If these drift, invoke silently serves published content
12
- * while the page shows the draft.
13
- */
14
- describe("draft constants parity with @decocms/blocks", () => {
15
- it("cookie name matches", () => {
16
- expect(DRAFT_COOKIE).toBe(DRAFT_COOKIE_NAME);
17
- });
18
- it("query param matches", () => {
19
- expect(DRAFT_PARAM).toBe(DRAFT_QUERY_PARAM);
20
- });
21
- });
@@ -1,15 +0,0 @@
1
- /**
2
- * Draft-preview constants with ZERO server imports.
3
- *
4
- * `draft.ts` statically imports `next/headers`, so anything reachable from a
5
- * `"use client"` file must not import from it — that would pull `next/headers`
6
- * into the client bundle and fail the build. The client-side badge
7
- * (`DraftPreviewBadge`) needs `DRAFT_PARAM` to build its links, so the shared
8
- * constants live here and `draft.ts` re-exports them for its own callers.
9
- */
10
-
11
- /** Query param that enters draft mode (and, with `off`, leaves it). */
12
- export const DRAFT_PARAM = "__draft";
13
-
14
- /** Cookie that carries the pointer across in-preview navigation. */
15
- export const DRAFT_COOKIE = "__deco_draft";
@@ -1,147 +0,0 @@
1
- import { NextRequest, NextResponse } from "next/server";
2
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
-
4
- import { DRAFT_COOKIE } from "./draft";
5
- import { applyDraft, prepareDraft, rewriteToDraftRoute } from "./draftMiddleware";
6
-
7
- beforeEach(() => {
8
- // The middleware is host-gated; these tests run as the allowed host.
9
- process.env.DECO_ALLOWED_PREVIEW_HOSTS = "site.example";
10
- });
11
- afterEach(() => {
12
- delete process.env.DECO_ALLOWED_PREVIEW_HOSTS;
13
- });
14
-
15
- function request(url: string, cookie?: string): NextRequest {
16
- const req = new NextRequest(new URL(url), {
17
- headers: cookie ? { cookie: `${DRAFT_COOKIE}=${cookie}` } : {},
18
- });
19
- return req;
20
- }
21
-
22
- describe("prepareDraft", () => {
23
- it("reads the pointer from the param", () => {
24
- expect(prepareDraft(request("https://site.example/p?__draft=abc.localhost@v1")).pointer).toBe(
25
- "abc.localhost@v1",
26
- );
27
- });
28
-
29
- it("reads the pointer from the cookie on a plain navigation", () => {
30
- expect(prepareDraft(request("https://site.example/p", "abc.localhost@v1")).pointer).toBe(
31
- "abc.localhost@v1",
32
- );
33
- });
34
-
35
- it("passes through when no env override is set — the page gate is authoritative", () => {
36
- // The site-block hosts live in the server-components graph; middleware
37
- // can't see them, so without the env kill switch it must not block.
38
- delete process.env.DECO_ALLOWED_PREVIEW_HOSTS;
39
- const req = request("https://anything.example/p?__draft=abc.localhost@v1");
40
- expect(prepareDraft(req).pointer).toBe("abc.localhost@v1");
41
- });
42
-
43
- it("is inert on a host outside the allowlist — the production domain", () => {
44
- // Same build, different Host: no cookie, no rewrite, nothing touched.
45
- const req = request("https://fila.com.br/p?__draft=abc@v1");
46
- process.env.DECO_ALLOWED_PREVIEW_HOSTS = "fila.vtex.app";
47
- expect(prepareDraft(req)).toEqual({ pointer: null, setCookie: null, clearCookie: false });
48
- expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
49
- });
50
-
51
- it("ignores a client-supplied draft header — the page owns the decision", () => {
52
- // There is no request-header path any more; the page reads searchParams
53
- // and cookies itself, so a forged header is simply not an input.
54
- const req = new NextRequest(new URL("https://site.example/p"), {
55
- headers: { "x-deco-draft": "attacker.localhost@v1" },
56
- });
57
- expect(prepareDraft(req).pointer).toBeNull();
58
- });
59
- });
60
-
61
- describe("applyDraft", () => {
62
- it("sets a partitioned cross-site cookie when entering draft mode", () => {
63
- const decision = prepareDraft(request("https://site.example/p?__draft=abc.localhost@v1"));
64
- const res = applyDraft(NextResponse.next(), decision);
65
-
66
- const setCookie = res.headers.get("set-cookie") ?? "";
67
- expect(setCookie).toContain(`${DRAFT_COOKIE}=abc.localhost%40v1`);
68
- // The preview iframe is cross-site, so these attributes are what make the
69
- // cookie survive at all — not hardening extras.
70
- expect(setCookie).toMatch(/SameSite=None/i);
71
- expect(setCookie).toMatch(/Secure/i);
72
- expect(setCookie).toMatch(/Partitioned/i);
73
- expect(setCookie).toMatch(/HttpOnly/i);
74
- });
75
-
76
- it("marks a draft response uncacheable and unindexable", () => {
77
- // With the pointer in a cookie, draft and published share a URL — a CDN
78
- // keyed on URL alone would serve unpublished content to a real visitor.
79
- const decision = prepareDraft(request("https://site.example/p", "abc.localhost@v1"));
80
- const res = applyDraft(NextResponse.next(), decision);
81
-
82
- expect(res.headers.get("cache-control")).toBe("no-store, private");
83
- expect(res.headers.get("vary")).toBe("Cookie");
84
- expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow");
85
- });
86
-
87
- it("leaves an ordinary response completely untouched", () => {
88
- const decision = prepareDraft(request("https://site.example/p"));
89
- const res = applyDraft(NextResponse.next(), decision);
90
-
91
- expect(res.headers.get("cache-control")).toBeNull();
92
- expect(res.headers.get("vary")).toBeNull();
93
- expect(res.headers.get("x-robots-tag")).toBeNull();
94
- expect(res.headers.get("set-cookie")).toBeNull();
95
- });
96
-
97
- it("clears the cookie on ?__draft=off", () => {
98
- const decision = prepareDraft(
99
- request("https://site.example/p?__draft=off", "abc.localhost@v1"),
100
- );
101
- expect(decision.clearCookie).toBe(true);
102
-
103
- const res = applyDraft(NextResponse.next(), decision);
104
- const setCookie = res.headers.get("set-cookie") ?? "";
105
- // Deletion is a set with an immediate expiry.
106
- expect(setCookie).toContain(DRAFT_COOKIE);
107
- expect(setCookie).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/i);
108
- // And the response is not treated as a draft render.
109
- expect(res.headers.get("cache-control")).toBeNull();
110
- });
111
- });
112
-
113
- describe("rewriteToDraftRoute", () => {
114
- it("rewrites a drafted request onto the dynamic draft route", () => {
115
- const req = request("https://site.example/blog/hello?__draft=abc.localhost@v1");
116
- const res = rewriteToDraftRoute(req, prepareDraft(req));
117
- const target = new URL(res?.headers.get("x-middleware-rewrite") ?? "");
118
- expect(target.pathname).toBe("/_draft/blog/hello");
119
- // The pointer must survive: the draft page reads it from searchParams.
120
- expect(target.searchParams.get("__draft")).toBe("abc.localhost@v1");
121
- });
122
-
123
- it("rewrites a cookie-only navigation too", () => {
124
- const req = request("https://site.example/blog/hello", "abc.localhost@v1");
125
- const res = rewriteToDraftRoute(req, prepareDraft(req));
126
- expect(new URL(res?.headers.get("x-middleware-rewrite") ?? "").pathname).toBe(
127
- "/_draft/blog/hello",
128
- );
129
- });
130
-
131
- it("leaves an ordinary request alone — ISR must not be disturbed", () => {
132
- // The whole point: only drafted requests go dynamic. A shopper's request
133
- // must reach the real, statically rendered route untouched.
134
- const req = request("https://site.example/blog/hello");
135
- expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
136
- });
137
-
138
- it("never nests the prefix when middleware re-runs on the rewritten URL", () => {
139
- const req = request("https://site.example/_draft/blog/hello?__draft=abc.localhost@v1");
140
- expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
141
- });
142
-
143
- it("does not rewrite when leaving draft mode", () => {
144
- const req = request("https://site.example/blog/hello?__draft=off", "abc.localhost@v1");
145
- expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
146
- });
147
- });
@@ -1,136 +0,0 @@
1
- /**
2
- * Draft preview — middleware half.
3
- *
4
- * Two composable calls, because sites already own their middleware and this
5
- * must slot into it rather than replace it:
6
- *
7
- * ```ts
8
- * // middleware.ts (Next <16) / proxy.ts (Next 16+)
9
- * export function middleware(request: NextRequest) {
10
- * const decision = prepareDraft(request);
11
- * return applyDraft(NextResponse.next(), decision);
12
- * }
13
- * ```
14
- *
15
- * The middleware owns only the cookie and the cache/indexing headers — a
16
- * Server Component can read cookies but cannot set them, so that part has to
17
- * live here. The pointer itself is read by the page.
18
- *
19
- * Lives on its own subpath so middleware (edge runtime) never imports the root
20
- * barrel, which pulls in the client component graph.
21
- */
22
-
23
- import { isDraftHostAllowed } from "@decocms/blocks/cms";
24
- import { type NextRequest, NextResponse } from "next/server";
25
- import {
26
- DRAFT_COOKIE,
27
- DRAFT_COOKIE_OPTIONS,
28
- type DraftMiddlewareDecision,
29
- decideDraft,
30
- } from "./draft";
31
-
32
- /**
33
- * Compute the draft decision for a request.
34
- *
35
- * Only the cookie and the response headers are the middleware's business now —
36
- * the page reads the pointer itself from `searchParams` + `cookies()`, so
37
- * nothing has to be forwarded through the request. That also means a draft
38
- * still works on routes this middleware never matches.
39
- */
40
- const INERT: DraftMiddlewareDecision = { pointer: null, setCookie: null, clearCookie: false };
41
-
42
- export function prepareDraft(request: NextRequest): DraftMiddlewareDecision {
43
- // The AUTHORITATIVE host gate lives page-side (`ensureDraft`): the site
44
- // block's `previewHosts` are installed in the server-components graph, and
45
- // middleware runs in a separate module graph/runtime that never sees them.
46
- // Middleware therefore hard-gates only on the env override — keeping
47
- // DECO_ALLOWED_PREVIEW_HOSTS a full kill switch — and otherwise passes the
48
- // decision through. Worst case on a non-preview host is a cookie the
49
- // page-side gate then ignores; drafts never render there.
50
- if (process.env.DECO_ALLOWED_PREVIEW_HOSTS) {
51
- const host =
52
- request.headers.get("x-forwarded-host") ??
53
- request.headers.get("host") ??
54
- request.nextUrl.host;
55
- if (!isDraftHostAllowed(host)) return INERT;
56
- }
57
- return decideDraft(new URL(request.url), request.cookies.get(DRAFT_COOKIE)?.value ?? null);
58
- }
59
-
60
- /**
61
- * URL prefix a drafted request is rewritten onto.
62
- *
63
- * The site must mount the matching route as `app/%5Fdraft/...` — URL-encoded.
64
- * A literal `_draft/` directory is a Next "private folder", excluded from
65
- * routing entirely, so the rewrite would fall through to whatever catch-all
66
- * follows and 404. The encoded directory serves the same `/_draft` URL while
67
- * staying routable.
68
- */
69
- export const DRAFT_ROUTE_PREFIX = "/_draft";
70
-
71
- /**
72
- * Rewrite a drafted request onto the dynamic draft route.
73
- *
74
- * This exists because `dynamic` / `revalidate` are STATIC route exports: a
75
- * statically rendered page cannot become dynamic for one request. Making the
76
- * real route dynamic to support drafts would cost every shopper their cached
77
- * page, and — worse — a statically rendered draft would be cached and served to
78
- * them. Rewriting sends only drafted requests to a route that is dynamic by
79
- * construction, leaving ordinary traffic's ISR completely untouched.
80
- *
81
- * The original path is preserved in the rewritten pathname, so the draft route
82
- * can resolve exactly the page the visitor asked for.
83
- *
84
- * Returns null when this request is not drafted, so callers fall through to
85
- * their normal response.
86
- */
87
- export function rewriteToDraftRoute(
88
- request: NextRequest,
89
- decision: DraftMiddlewareDecision,
90
- ): NextResponse | null {
91
- if (!decision.pointer) return null;
92
- const url = request.nextUrl.clone();
93
- // Already rewritten (Next re-runs middleware on the rewritten URL in some
94
- // configurations) — never nest the prefix.
95
- if (url.pathname.startsWith(`${DRAFT_ROUTE_PREFIX}/`)) return null;
96
- url.pathname = `${DRAFT_ROUTE_PREFIX}${url.pathname}`;
97
- return NextResponse.rewrite(url);
98
- }
99
-
100
- /**
101
- * Apply the cookie and the cache/indexing headers a draft response requires.
102
- *
103
- * The caching headers are the difference between a preview and a **leak**.
104
- * With the pointer in a cookie, a draft response and a published one share an
105
- * identical URL, so a CDN keyed on URL alone would happily serve unpublished
106
- * content to a real visitor. `no-store` is what actually prevents that;
107
- * `Vary: Cookie` keeps any intermediary that *does* respect it from mixing the
108
- * two; `X-Robots-Tag` keeps a leaked draft out of search results.
109
- */
110
- export function applyDraft(
111
- response: NextResponse,
112
- decision: DraftMiddlewareDecision,
113
- ): NextResponse {
114
- if (decision.clearCookie) {
115
- response.cookies.delete(DRAFT_COOKIE);
116
- } else if (decision.setCookie) {
117
- response.cookies.set(DRAFT_COOKIE, decision.setCookie, DRAFT_COOKIE_OPTIONS);
118
- }
119
-
120
- if (decision.pointer) {
121
- response.headers.set("cache-control", "no-store, private");
122
- response.headers.set("vary", "Cookie");
123
- response.headers.set("x-robots-tag", "noindex, nofollow");
124
- }
125
-
126
- return response;
127
- }
128
-
129
- /**
130
- * Convenience for sites with no middleware of their own: prepare, continue,
131
- * apply. Sites that already have middleware should call the two halves
132
- * directly so their own logic sits in between.
133
- */
134
- export function draftMiddleware(request: NextRequest): NextResponse {
135
- return applyDraft(NextResponse.next(), prepareDraft(request));
136
- }
@@ -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
- });