@decocms/nextjs 7.3.1 → 7.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 ADDED
@@ -0,0 +1,270 @@
1
+ # `@decocms/nextjs`
2
+
3
+ Deco framework binding for Next.js App Router — the Next.js sibling of
4
+ `@decocms/tanstack`. Three surfaces, composed together:
5
+
6
+ - **`@decocms/nextjs/config`** — `withDeco(nextConfig)`, a `next.config`
7
+ wrapper that adds the rewrites and `transpilePackages` Deco's admin
8
+ protocol needs.
9
+ - **`@decocms/nextjs/routeHandlers`** — `createDecoRouteHandlers({ setup })`,
10
+ a single catch-all dispatcher for the whole Studio admin protocol
11
+ (decofile read/reload, meta schema, invoke, live previews).
12
+ - **`@decocms/nextjs/setup`** — `createNextSetup(options)`, a one-call site
13
+ bootstrap: the Next.js analogue of Vite's
14
+ `createSiteSetup` + `createAdminSetup` + `import.meta.glob`.
15
+
16
+ This document is the complete recipe a new Next.js site follows to wire all
17
+ three together. Every code block below is meant to be copied, not
18
+ paraphrased.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ bun add @decocms/nextjs @decocms/blocks @decocms/blocks-admin
24
+ ```
25
+
26
+ `next`, `react`, and `react-dom` are peer dependencies — the site already
27
+ has them.
28
+
29
+ ## 1. `next.config` — `withDeco`
30
+
31
+ `withDeco` adds three rewrites (mapping the Studio-protocol URLs Next.js
32
+ cannot express as route segments onto `/deco/*`, where the catch-all route
33
+ below serves them) and appends the three `@decocms/*` packages (which ship
34
+ raw TypeScript, not a prebuilt `dist/`) to `transpilePackages`.
35
+
36
+ TypeScript (`next.config.ts`):
37
+
38
+ ```ts
39
+ import type { NextConfig } from "next";
40
+ import { withDeco } from "@decocms/nextjs/config";
41
+
42
+ const nextConfig: NextConfig = {
43
+ // ...your own config
44
+ };
45
+
46
+ export default withDeco(nextConfig);
47
+ ```
48
+
49
+ CommonJS (`next.config.js`) — most Next.js sites still use this form:
50
+
51
+ ```js
52
+ /** @type {import('next').NextConfig} */
53
+ const { withDeco } = require("@decocms/nextjs/config");
54
+
55
+ const nextConfig = {
56
+ // ...your own config
57
+ };
58
+
59
+ module.exports = withDeco(nextConfig);
60
+ ```
61
+
62
+ `withDeco` merges with a `rewrites`/`transpilePackages` you already have —
63
+ it never replaces them. If your own `rewrites()` returns the array form,
64
+ Deco's rewrites are prepended; if it returns the `{ beforeFiles, afterFiles,
65
+ fallback }` object form, Deco's rewrites are prepended to `beforeFiles`.
66
+
67
+ ## 2. The catch-all route
68
+
69
+ Mount `createDecoRouteHandlers` at `app/deco/[[...deco]]/route.ts` — this
70
+ one file serves the entire admin protocol (decofile, meta, invoke, live
71
+ previews) for both the rewritten public URLs (`/.decofile`, `/live/_meta`,
72
+ `/live/previews/*`) and their `/deco/*` destinations directly.
73
+
74
+ ```ts
75
+ // src/app/deco/[[...deco]]/route.ts
76
+ import { createDecoRouteHandlers } from "@decocms/nextjs/routeHandlers";
77
+ import { ensureSetup } from "../../../deco/setup";
78
+
79
+ export const dynamic = "force-dynamic";
80
+
81
+ export const { GET, POST } = createDecoRouteHandlers({ setup: ensureSetup });
82
+ ```
83
+
84
+ `dynamic = "force-dynamic"` is required — this route must never be
85
+ statically cached (decofile reloads, invoke calls, and previews all need a
86
+ fresh response per request).
87
+
88
+ ### Route-handler import rule: subpaths, never the root barrel
89
+
90
+ **Always import from `@decocms/nextjs/routeHandlers` (or `/config`,
91
+ `/setup`) in a `route.ts` file. Never `import { ... } from "@decocms/nextjs"`
92
+ there.**
93
+
94
+ App Router route handlers evaluate their entire module graph against
95
+ React's **react-server** build, and this happens regardless of any
96
+ `"use client"` directive in that graph — route handlers have no client
97
+ bundle to move a `"use client"` module into, so the directive is simply
98
+ ignored. The root barrel (`@decocms/nextjs`'s `.` export) re-exports the
99
+ render components (`DecoRootLayout`, `SectionRenderer`, `DecoPageRenderer`,
100
+ ...), whose module graph reaches component code that uses client-only React
101
+ APIs (`createContext`, `useContext`, class components with
102
+ `componentDidCatch`, etc.) at module scope. Importing the root barrel from
103
+ a route file pulls that whole graph into react-server evaluation and
104
+ crashes at **import time**, before your handler ever runs, with errors like
105
+ `"...createContext is not a function"` or `"Class extends value undefined
106
+ is not a constructor"` — even if the route handler itself never touches
107
+ those components. The `/routeHandlers`, `/config`, and `/setup` subpaths
108
+ are each scoped to keep their own module graph free of component code, so
109
+ they're safe to import from anywhere, including a route file.
110
+
111
+ ## 3. `src/deco/setup.ts` — `createNextSetup`
112
+
113
+ The codegen artifacts (`sections.gen.ts`, `meta.gen.json`) are generated into
114
+ `.deco/` at the site root — the same default `@decocms/blocks-cli`'s
115
+ generators use everywhere else (framework artifacts live in the framework's
116
+ folder, not scattered across `src/`). `src/deco/setup.ts` isn't adjacent to
117
+ `.deco/`, so import it through a `deco/*` tsconfig path alias instead of a
118
+ relative path:
119
+
120
+ ```json
121
+ // tsconfig.json
122
+ {
123
+ "compilerOptions": {
124
+ "paths": {
125
+ "deco/*": [".deco/*"]
126
+ }
127
+ }
128
+ }
129
+ ```
130
+
131
+ ```ts
132
+ // src/deco/setup.ts
133
+ import { createNextSetup } from "@decocms/nextjs/setup";
134
+ import { sectionImports, sectionMeta, syncComponents, loadingFallbacks } from "deco/sections.gen";
135
+
136
+ export const ensureSetup = createNextSetup({
137
+ sections: sectionImports,
138
+ conventions: { meta: sectionMeta, syncComponents, loadingFallbacks },
139
+ meta: () => import("deco/meta.gen.json").then((m) => m.default),
140
+ });
141
+ ```
142
+
143
+ `createNextSetup` returns a **memoized** `ensureSetup()` function — a
144
+ successful bootstrap is cached for the life of the warm serverless
145
+ instance; a *rejected* bootstrap clears the memo so the next call retries
146
+ from scratch (the triggering call still rejects with the original error).
147
+
148
+ Two call sites need `ensureSetup()`:
149
+
150
+ 1. **The catch-all route** (above) passes it as `{ setup: ensureSetup }` —
151
+ `createDecoRouteHandlers` awaits it before dispatching every admin
152
+ request.
153
+ 2. **The root layout** (`app/layout.tsx`) must await it directly before
154
+ rendering, since page rendering (`createDecoPage`'s resolver) has no
155
+ setup hook of its own:
156
+
157
+ ```tsx
158
+ // src/app/layout.tsx
159
+ import { DecoRootLayout } from "@decocms/nextjs";
160
+ import { ensureSetup } from "../deco/setup";
161
+
162
+ export default async function RootLayout({ children }: { children: React.ReactNode }) {
163
+ await ensureSetup();
164
+ return <DecoRootLayout siteName="my-site">{children}</DecoRootLayout>;
165
+ }
166
+ ```
167
+
168
+ (`app/layout.tsx` is a Server Component, not a route handler, so it's
169
+ fine to import the root barrel here — see the rule above.)
170
+
171
+ ### `NextSetupOptions` at a glance
172
+
173
+ | Option | Purpose |
174
+ | --- | --- |
175
+ | `blocksDir` | Directory of decofile JSON snapshots (`.deco/blocks` by default). Pass `false` to skip filesystem loading entirely (e.g. when all content comes from `blocks`). |
176
+ | `blocks` | Extra/override blocks, merged **over** the directory's blocks. |
177
+ | `sections` | The lazy section registry — `sectionImports` from `generate-sections --registry` (see below). |
178
+ | `conventions` | `{ meta, syncComponents, loadingFallbacks }` from `sections.gen.ts` — wires the `export const sync/layout/seo/cache/eager/clientOnly` conventions (see below). |
179
+ | `meta` | Lazy admin meta schema loader: `() => import("deco/meta.gen.json").then(m => m.default)`. Wire this even with a trivial schema — without it, `/deco/meta` (and its `/live/_meta` alias) 503s with `"Schema not initialized"`. |
180
+ | `renderShell` | Admin preview shell config (`{ css, fonts }`). |
181
+ | `previewWrapper` | Admin preview wrapper component. |
182
+ | `productionOrigins`, `customMatchers`, `onResolveError`, `onDanglingReference` | Passed through to `createSiteSetup`. |
183
+ | `extend` | Site-specific wiring that must run *after* core setup (section loaders, legacy SEO key shims, curated post-processing). Receives the loaded blocks. |
184
+
185
+ ## 4. `package.json` scripts — non-colliding names
186
+
187
+ Add two codegen scripts. **Do not name either of these `generate:schema`** —
188
+ FastStore sites already own that script name for their own commerce-schema
189
+ codegen, and a collision silently shadows one of the two generators
190
+ depending on script-merge order. Use these names instead:
191
+
192
+ ```json
193
+ {
194
+ "scripts": {
195
+ "generate:deco-meta": "tsx node_modules/@decocms/blocks-cli/scripts/generate-schema.ts",
196
+ "generate:deco-sections": "tsx node_modules/@decocms/blocks-cli/scripts/generate-sections.ts --registry"
197
+ }
198
+ }
199
+ ```
200
+
201
+ Neither script needs an `--out`/`--out-file` flag — both generators default
202
+ to `.deco/`, which is exactly where `src/deco/setup.ts` reads them from via
203
+ the `deco/*` path alias (see above). Only pass `--out`/`--out-file` if your
204
+ site has a reason to put the artifact somewhere else.
205
+
206
+ - `generate:deco-meta` runs `blocks-cli`'s `generate-schema.ts`, which scans
207
+ `src/sections/`, `src/loaders/`, and `src/apps/` for `Props` interfaces
208
+ and emits the JSON Schema the admin's `/deco/meta` endpoint serves, to
209
+ `.deco/meta.gen.json` by default.
210
+ - `generate:deco-sections` runs `generate-sections.ts` **with `--registry`**
211
+ — the flag that additionally emits `sectionImports`, the Next.js/webpack
212
+ equivalent of Vite's `import.meta.glob("./sections/**/*.tsx")` (Next has
213
+ no `import.meta.glob` or Vite plugin, so this is generated instead of
214
+ computed at build time). Without `--registry` you only get
215
+ `sectionMeta`/`syncComponents`/`loadingFallbacks`, not the lazy loader map
216
+ `setup.ts` needs for its `sections` option. Defaults to
217
+ `.deco/sections.gen.ts`.
218
+
219
+ Run both any time `src/sections/` changes (wire into a `predev`/`prebuild`
220
+ step, or a watch script, as your site prefers).
221
+
222
+ ## 5. `src/sections/` — the entry-file convention
223
+
224
+ Every non-test `.tsx`/`.ts` file directly under `src/sections/` (recursively)
225
+ becomes a section key, keyed as `site/sections/<path-relative-to-sections-dir>`.
226
+ **This is not opt-in** — `generate-sections.ts` walks the whole directory
227
+ and turns every matching file into a registry entry, whether or not it
228
+ carries any convention exports. Files ending in `.test.ts(x)`, `.spec.ts(x)`,
229
+ `.stories.ts(x)`, or `.gen.ts(x)` are the only ones excluded.
230
+
231
+ The established pattern is a **thin re-export entry file** per section, with
232
+ the actual component implementation living elsewhere (e.g. alongside its
233
+ own subcomponents, styles, and tests, outside `src/sections/`):
234
+
235
+ ```ts
236
+ // src/sections/Hero.tsx — thin entry file, becomes "site/sections/Hero.tsx"
237
+ export { default } from "../components/Hero/Hero";
238
+ export type { HeroProps as Props } from "../components/Hero/Hero";
239
+ ```
240
+
241
+ This keeps `src/sections/` a clean, flat index of exactly what's
242
+ CMS-addressable, instead of a directory contest between "real" component
243
+ code and registry wiring.
244
+
245
+ ### Convention exports
246
+
247
+ A section's entry file (or the file it re-exports from — the scanner reads
248
+ the entry file's own source, so re-exported `export const` conventions must
249
+ be re-declared or forwarded on the entry file itself, not just the
250
+ implementation file) can opt into these, each read as a literal
251
+ `export const <name> = <value>`:
252
+
253
+ | Export | Effect |
254
+ | --- | --- |
255
+ | `export const sync = true` | Bundled synchronously (not lazy-loaded) — for above-the-fold, always-rendered sections. |
256
+ | `export const layout = true` | Cached as a layout section (Header, Footer, Theme) — resolved once, not per-page. |
257
+ | `export const seo = true` | SEO section — its resolved props are merged into page-level SEO. |
258
+ | `export const cache = "listing"` | SWR-cached section loader results, keyed by the given cache name. |
259
+ | `export const eager = true` | Prefers eager rendering (defers only past the fold threshold). |
260
+ | `export const neverDefer = true` | Always eager, ignoring the fold threshold entirely. |
261
+ | `export const clientOnly = true` | Skips SSR — client-only rendering. |
262
+ | `export function LoadingFallback` | Skeleton component shown while the section loads. |
263
+
264
+ ## Verifying your setup
265
+
266
+ `examples/nextjs-smoke` in this monorepo is a minimal, real Next.js App
267
+ Router build exercising all three surfaces end to end — `withDeco` rewrites,
268
+ the catch-all route, `createNextSetup`, and a resolved page render. Use it
269
+ as a working reference if any of the above doesn't compose the way you
270
+ expect.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/nextjs",
3
- "version": "7.3.1",
3
+ "version": "7.5.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for Next.js App Router",
6
6
  "repository": {
@@ -11,7 +11,12 @@
11
11
  "main": "./src/index.ts",
12
12
  "exports": {
13
13
  ".": "./src/index.ts",
14
- "./routeHandlers": "./src/routeHandlers.ts"
14
+ "./routeHandlers": "./src/routeHandlers.ts",
15
+ "./setup": "./src/setup.ts",
16
+ "./config": {
17
+ "types": "./src/config.d.cts",
18
+ "default": "./src/config.cjs"
19
+ }
15
20
  },
16
21
  "scripts": {
17
22
  "build": "tsc",
@@ -20,8 +25,8 @@
20
25
  "lint:unused": "knip"
21
26
  },
22
27
  "dependencies": {
23
- "@decocms/blocks": "7.3.1",
24
- "@decocms/blocks-admin": "7.3.1"
28
+ "@decocms/blocks": "7.5.0",
29
+ "@decocms/blocks-admin": "7.5.0"
25
30
  },
26
31
  "peerDependencies": {
27
32
  "next": ">=15.0.0",
package/src/config.cjs ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * next.config wrapper for Deco sites. CommonJS on purpose: next.config.js
3
+ * is CJS in most sites and this package is "type": "module", so a .js
4
+ * file here would be ESM and unrequireable on Node < 22.
5
+ *
6
+ * Adds:
7
+ * 1. Rewrites for the Studio-protocol URLs Next cannot express as route
8
+ * segments — `/.decofile` (segments can't start with a dot) and
9
+ * `/live/_meta` (`_`-prefixed segments are Next "private folders",
10
+ * silently excluded from routing) — plus `/live/previews/*`, all
11
+ * funneled to `/deco/*` where a single catch-all route
12
+ * (`app/deco/[[...deco]]/route.ts` + createDecoRouteHandlers) serves
13
+ * the whole protocol.
14
+ * 2. transpilePackages for the raw-TS @decocms packages.
15
+ */
16
+ const DECO_REWRITES = [
17
+ { source: "/.decofile", destination: "/deco/decofile" },
18
+ { source: "/live/_meta", destination: "/deco/meta" },
19
+ { source: "/live/previews/:path*", destination: "/deco/previews/:path*" },
20
+ ];
21
+
22
+ const DECO_TRANSPILE = ["@decocms/blocks", "@decocms/blocks-admin", "@decocms/nextjs"];
23
+
24
+ function withDeco(nextConfig = {}) {
25
+ const userRewrites = nextConfig.rewrites;
26
+ return {
27
+ ...nextConfig,
28
+ transpilePackages: [...new Set([...(nextConfig.transpilePackages ?? []), ...DECO_TRANSPILE])],
29
+ async rewrites() {
30
+ const user = typeof userRewrites === "function" ? await userRewrites() : (userRewrites ?? []);
31
+ if (Array.isArray(user)) return [...DECO_REWRITES, ...user];
32
+ return { ...user, beforeFiles: [...DECO_REWRITES, ...(user.beforeFiles ?? [])] };
33
+ },
34
+ };
35
+ }
36
+
37
+ module.exports = { withDeco, DECO_REWRITES };
@@ -0,0 +1,4 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ export declare const DECO_REWRITES: Array<{ source: string; destination: string }>;
4
+ export declare function withDeco(nextConfig?: NextConfig): NextConfig;
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { DECO_REWRITES, withDeco } from "./config.cjs";
3
+
4
+ // withDeco()'s declared return type is the standard (loosely typed) Next.js
5
+ // NextConfig, whose `rewrites`/`transpilePackages` are optional and
6
+ // union-typed. The runtime object always has them (withDeco always sets
7
+ // them), so tests assert that with `!` / casts rather than loosening the
8
+ // public declaration.
9
+ describe("withDeco", () => {
10
+ it("adds rewrites and transpilePackages to a bare config", async () => {
11
+ const cfg = withDeco({});
12
+ expect(cfg.transpilePackages).toEqual(
13
+ expect.arrayContaining(["@decocms/blocks", "@decocms/blocks-admin", "@decocms/nextjs"]),
14
+ );
15
+ expect(await cfg.rewrites!()).toEqual(DECO_REWRITES);
16
+ });
17
+
18
+ it("prepends deco rewrites to a user's array-returning rewrites()", async () => {
19
+ const cfg = withDeco({
20
+ rewrites: async () => [{ source: "/a", destination: "/b" }],
21
+ });
22
+ const out = (await cfg.rewrites!()) as Array<{ source: string; destination: string }>;
23
+ expect(out.slice(0, DECO_REWRITES.length)).toEqual(DECO_REWRITES);
24
+ expect(out.at(-1)).toEqual({ source: "/a", destination: "/b" });
25
+ });
26
+
27
+ it("merges into a user's object-form rewrites via beforeFiles", async () => {
28
+ const cfg = withDeco({
29
+ rewrites: async () => ({
30
+ beforeFiles: [{ source: "/x", destination: "/y" }],
31
+ afterFiles: [],
32
+ fallback: [],
33
+ }),
34
+ });
35
+ const out = (await cfg.rewrites!()) as {
36
+ beforeFiles: Array<{ source: string; destination: string }>;
37
+ };
38
+ expect(out.beforeFiles.slice(0, DECO_REWRITES.length)).toEqual(DECO_REWRITES);
39
+ expect(out.beforeFiles.at(-1)).toEqual({ source: "/x", destination: "/y" });
40
+ });
41
+
42
+ it("dedupes transpilePackages", () => {
43
+ const cfg = withDeco({ transpilePackages: ["@decocms/blocks", "other"] });
44
+ expect(cfg.transpilePackages!.filter((p: string) => p === "@decocms/blocks")).toHaveLength(1);
45
+ expect(cfg.transpilePackages).toContain("other");
46
+ });
47
+ });
@@ -1,6 +1,27 @@
1
- import { describe, expect, it } from "vitest";
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
+ }));
21
+ vi.mock("@decocms/blocks-admin", () => mocks);
22
+
2
23
  import { setMetaData } from "@decocms/blocks-admin";
3
- import { metaGET } from "./routeHandlers";
24
+ import { createDecoRouteHandlers, metaGET } from "./routeHandlers";
4
25
 
5
26
  describe("routeHandlers (next)", () => {
6
27
  it("metaGET returns the schema response", async () => {
@@ -9,3 +30,124 @@ describe("routeHandlers (next)", () => {
9
30
  expect(response.status).toBe(200);
10
31
  });
11
32
  });
33
+
34
+ describe("createDecoRouteHandlers", () => {
35
+ beforeEach(() => vi.clearAllMocks());
36
+
37
+ it("runs setup before dispatching and routes decofile GET/POST", async () => {
38
+ const order: string[] = [];
39
+ const setup = vi.fn(async () => {
40
+ order.push("setup");
41
+ });
42
+ const { GET, POST } = createDecoRouteHandlers({ setup });
43
+
44
+ await GET(new Request("http://x/deco/decofile"));
45
+ expect(setup).toHaveBeenCalled();
46
+ expect(mocks.handleDecofileRead).toHaveBeenCalled();
47
+
48
+ await POST(new Request("http://x/deco/decofile", { method: "POST" }));
49
+ expect(mocks.handleDecofileReload).toHaveBeenCalled();
50
+ });
51
+
52
+ it("routes meta, render, and invoke", async () => {
53
+ const { GET, POST } = createDecoRouteHandlers();
54
+ await GET(new Request("http://x/deco/meta"));
55
+ expect(mocks.handleMeta).toHaveBeenCalled();
56
+ await POST(new Request("http://x/deco/render", { method: "POST" }));
57
+ expect(mocks.handleRender).toHaveBeenCalled();
58
+ await POST(new Request("http://x/deco/invoke/site/actions/x", { method: "POST" }));
59
+ expect(mocks.handleInvoke).toHaveBeenCalled();
60
+ });
61
+
62
+ it("rebuilds /deco/previews/* URLs to the /live/previews/* prefix handleRender parses", async () => {
63
+ const { GET } = createDecoRouteHandlers();
64
+ const res = await GET(new Request("http://x/deco/previews/pages-Home-123?props=x"));
65
+ expect(await res.text()).toBe("/live/previews/pages-Home-123");
66
+ const calledUrl = new URL(mocks.handleRender.mock.calls[0][0].url);
67
+ expect(calledUrl.searchParams.get("props")).toBe("x");
68
+ });
69
+
70
+ it("forwards a POST body through the /deco/previews/* URL rebuild (Request-as-init carries the body stream)", async () => {
71
+ // Pins the `new Request(rebuilt, request)` semantics documented on the
72
+ // rebuild line: passing a Request as `init` clones the body stream
73
+ // without a `duplex` option. A refactor to a plain init object would
74
+ // throw "duplex option is required" for this POST body.
75
+ mocks.handleRender.mockImplementationOnce(async (req: Request) => {
76
+ const body = await req.json();
77
+ return new Response(JSON.stringify({ pathname: new URL(req.url).pathname, body }));
78
+ });
79
+ const { POST } = createDecoRouteHandlers();
80
+ const res = await POST(
81
+ new Request("http://x/deco/previews/pages-Home-123", {
82
+ method: "POST",
83
+ headers: { "Content-Type": "application/json" },
84
+ body: JSON.stringify({ hello: "world" }),
85
+ }),
86
+ );
87
+ const json = await res.json();
88
+ expect(json.pathname).toBe("/live/previews/pages-Home-123");
89
+ expect(json.body).toEqual({ hello: "world" });
90
+ });
91
+
92
+ it("404s unknown deco paths", async () => {
93
+ const { GET } = createDecoRouteHandlers();
94
+ const res = await GET(new Request("http://x/deco/nope"));
95
+ expect(res.status).toBe(404);
96
+ });
97
+
98
+ // Regression coverage for the rewrite-source paths, not just their
99
+ // /deco/* destinations: a Next.js App Router route handler reached via a
100
+ // next.config.js `rewrites()` entry sees `request.url` as the ORIGINAL,
101
+ // pre-rewrite path (verified empirically against a real `next build` +
102
+ // `next start`) — NOT the /deco/* destination the rewrite maps to. Every
103
+ // test above this one only ever constructs an already-/deco/*-shaped
104
+ // Request, so it would keep passing even if the dispatcher only matched
105
+ // that form and 404'd every real rewritten request — which is exactly
106
+ // the bug these tests catch.
107
+ it("routes the rewrite-source /.decofile path (not just its /deco/decofile destination)", async () => {
108
+ const { GET, POST } = createDecoRouteHandlers();
109
+ await GET(new Request("http://x/.decofile"));
110
+ expect(mocks.handleDecofileRead).toHaveBeenCalled();
111
+ await POST(new Request("http://x/.decofile", { method: "POST" }));
112
+ expect(mocks.handleDecofileReload).toHaveBeenCalled();
113
+ });
114
+
115
+ it("routes the rewrite-source /live/_meta path (not just its /deco/meta destination)", async () => {
116
+ const { GET } = createDecoRouteHandlers();
117
+ const res = await GET(new Request("http://x/live/_meta"));
118
+ expect(mocks.handleMeta).toHaveBeenCalled();
119
+ expect(res.status).not.toBe(404);
120
+ });
121
+
122
+ it("405s POST /live/_meta with Allow: GET (the PRE-rewrite URL form)", async () => {
123
+ const { POST } = createDecoRouteHandlers();
124
+ const res = await POST(new Request("http://x/live/_meta", { method: "POST" }));
125
+ expect(res.status).toBe(405);
126
+ expect(res.headers.get("Allow")).toBe("GET");
127
+ expect(mocks.handleMeta).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it("routes the rewrite-source /live/previews/* path straight through to handleRender with the prefix intact", async () => {
131
+ const { GET } = createDecoRouteHandlers();
132
+ const res = await GET(new Request("http://x/live/previews/pages-Home-123?props=x"));
133
+ expect(await res.text()).toBe("/live/previews/pages-Home-123");
134
+ const calledUrl = new URL(mocks.handleRender.mock.calls[0][0].url);
135
+ expect(calledUrl.searchParams.get("props")).toBe("x");
136
+ });
137
+
138
+ it("405s GET /deco/invoke/* with Allow: POST (CSRF protection — see comment on the invoke branch)", async () => {
139
+ const { GET } = createDecoRouteHandlers();
140
+ const res = await GET(new Request("http://x/deco/invoke/site/actions/x"));
141
+ expect(res.status).toBe(405);
142
+ expect(res.headers.get("Allow")).toBe("POST");
143
+ expect(mocks.handleInvoke).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it("405s POST /deco/meta with Allow: GET", async () => {
147
+ const { POST } = createDecoRouteHandlers();
148
+ const res = await POST(new Request("http://x/deco/meta", { method: "POST" }));
149
+ expect(res.status).toBe(405);
150
+ expect(res.headers.get("Allow")).toBe("GET");
151
+ expect(mocks.handleMeta).not.toHaveBeenCalled();
152
+ });
153
+ });
@@ -52,3 +52,123 @@ export async function renderGET(request: Request): Promise<Response> {
52
52
  export async function renderPOST(request: Request): Promise<Response> {
53
53
  return handleRender(request);
54
54
  }
55
+
56
+ export interface DecoRouteHandlersOptions {
57
+ /**
58
+ * Site bootstrap, awaited before every admin request — pass the
59
+ * ensureSetup returned by createNextSetup (@decocms/nextjs/setup).
60
+ */
61
+ setup?: () => Promise<void>;
62
+ }
63
+
64
+ /**
65
+ * Single catch-all dispatcher for the whole Studio admin protocol. Mount
66
+ * at `app/deco/[[...deco]]/route.ts` and wrap next.config with
67
+ * `withDeco()` (@decocms/nextjs/config), whose rewrites map the protocol
68
+ * URLs Next can't express as segments (`/.decofile`, `/live/_meta`,
69
+ * `/live/previews/*`) into `/deco/*`:
70
+ *
71
+ * ```ts
72
+ * import { createDecoRouteHandlers } from "@decocms/nextjs/routeHandlers";
73
+ * import { ensureSetup } from "../../../deco/setup";
74
+ * export const dynamic = "force-dynamic";
75
+ * export const { GET, POST } = createDecoRouteHandlers({ setup: ensureSetup });
76
+ * ```
77
+ *
78
+ * `resolveAction` accepts BOTH the rewrite's public source path (e.g.
79
+ * `/.decofile`, `/live/_meta`, `/live/previews/*`) AND the rewrite's own
80
+ * destination path (`/deco/*`) — not just the latter. This is load-bearing:
81
+ * verified empirically against a real `next build && next start` (both
82
+ * dev and production servers) that a Next.js App Router route handler
83
+ * reached via a `next.config.js`-level `rewrites()` entry sees
84
+ * `request.url` (and `NextRequest.nextUrl.pathname`) as the ORIGINAL,
85
+ * pre-rewrite path the client requested — rewrites are transparent to the
86
+ * client and, in this respect, to the handler too. Only a *direct* request
87
+ * to `/deco/*` (bypassing the rewrite, e.g. `/deco/invoke/*`, which has no
88
+ * public alias) arrives with a `/deco/`-prefixed pathname. Matching only
89
+ * the `/deco/*` form (as an earlier version of this dispatcher did) makes
90
+ * every rewritten protocol URL 404 — the dispatcher never sees a
91
+ * `/deco/decofile`-shaped path in that case, it sees `/.decofile` verbatim.
92
+ */
93
+ function resolveAction(pathname: string): string {
94
+ if (pathname.startsWith("/deco/")) return pathname.slice("/deco/".length);
95
+ if (pathname === "/.decofile") return "decofile";
96
+ if (pathname === "/live/_meta") return "meta";
97
+ if (pathname === "/live/previews") return "previews";
98
+ if (pathname.startsWith("/live/previews/")) {
99
+ return `previews/${pathname.slice("/live/previews/".length)}`;
100
+ }
101
+ return pathname;
102
+ }
103
+
104
+ export function createDecoRouteHandlers(options: DecoRouteHandlersOptions = {}): {
105
+ GET(request: Request): Promise<Response>;
106
+ POST(request: Request): Promise<Response>;
107
+ } {
108
+ async function dispatch(request: Request): Promise<Response> {
109
+ await options.setup?.();
110
+
111
+ const url = new URL(request.url);
112
+ const action = resolveAction(url.pathname);
113
+
114
+ if (action === "decofile") {
115
+ return request.method === "POST" ? handleDecofileReload(request) : handleDecofileRead();
116
+ }
117
+ if (action === "meta") {
118
+ // GET only: this is a read-only schema endpoint, no legitimate POST use.
119
+ if (request.method !== "GET") {
120
+ return new Response(JSON.stringify({ error: "Method not allowed: meta is GET-only" }), {
121
+ status: 405,
122
+ headers: { "Content-Type": "application/json", Allow: "GET" },
123
+ });
124
+ }
125
+ return handleMeta(request);
126
+ }
127
+ if (action === "render") return handleRender(request);
128
+ if (action.startsWith("invoke/")) {
129
+ // POST only: handleInvoke has no auth of its own and falls back to a
130
+ // `?props=<json>` query string for GET requests. A GET is a CORS
131
+ // "simple request" (no preflight), so an unauthenticated
132
+ // `<img src="https://site/deco/invoke/site/actions/...?props=...">`
133
+ // on a third-party page would be able to trigger mutating VTEX
134
+ // actions cross-site (CSRF). The per-route `invokePOST` export this
135
+ // dispatcher replaces was POST-only for exactly this reason — keep
136
+ // that restriction here.
137
+ if (request.method !== "POST") {
138
+ return new Response(
139
+ JSON.stringify({ error: "Method not allowed: invoke is POST-only (CSRF protection)" }),
140
+ {
141
+ status: 405,
142
+ headers: { "Content-Type": "application/json", Allow: "POST" },
143
+ },
144
+ );
145
+ }
146
+ return handleInvoke(request);
147
+ }
148
+ if (action === "previews" || action.startsWith("previews/")) {
149
+ // handleRender parses the literal "/live/previews/" prefix.
150
+ // `resolveAction` above already normalizes BOTH a direct
151
+ // `/deco/previews/*` hit and a rewritten `/live/previews/*` hit down
152
+ // to this same `action` shape, so rebuilding unconditionally here is
153
+ // a no-op in the rewrite case (rebuilt === original) and the
154
+ // necessary reconstruction in the direct-hit case.
155
+ const rest = action === "previews" ? "" : action.slice("previews/".length);
156
+ const rebuilt = new URL(url);
157
+ rebuilt.pathname = `/live/previews/${rest}`;
158
+ // `request` (a Request) is passed as the `init` argument here, not a
159
+ // plain object — the Request-as-init form clones headers/method/body
160
+ // (including the body *stream*) without needing an explicit `duplex`
161
+ // option. Rewriting this as `new Request(rebuilt, { ...request })` or
162
+ // any plain-object init would throw "duplex option is required" for
163
+ // POST bodies with a body stream — see the test below that posts a
164
+ // JSON body through this branch and asserts it still parses.
165
+ return handleRender(new Request(rebuilt, request));
166
+ }
167
+ return new Response(JSON.stringify({ error: `Unknown deco route: ${url.pathname}` }), {
168
+ status: 404,
169
+ headers: { "Content-Type": "application/json" },
170
+ });
171
+ }
172
+
173
+ return { GET: dispatch, POST: dispatch };
174
+ }
@@ -0,0 +1,80 @@
1
+ // @vitest-environment node
2
+ //
3
+ // createSiteSetup (via @decocms/blocks/setup) only calls setBlocks() when
4
+ // `typeof document === "undefined"` — it's the server-only half of the
5
+ // Vite dual-environment split. Next.js Route Handlers run server-side with
6
+ // no DOM, which is exactly what createNextSetup targets, so this suite runs
7
+ // under vitest's "node" environment rather than the package default
8
+ // (jsdom, used by the component-rendering tests in this same package) to
9
+ // match that real invocation context.
10
+ import { beforeEach, describe, expect, it, vi } from "vitest";
11
+ import { listRegisteredSections, loadBlocks, setBlocks } from "@decocms/blocks/cms";
12
+ import { createNextSetup } from "./setup";
13
+
14
+ describe("createNextSetup", () => {
15
+ beforeEach(() => {
16
+ setBlocks({});
17
+ });
18
+
19
+ it("returns a memoized ensureSetup that registers blocks, sections, meta", async () => {
20
+ const meta = vi.fn().mockResolvedValue({
21
+ major: 1,
22
+ version: "test",
23
+ namespace: "site",
24
+ site: "test",
25
+ manifest: { blocks: { sections: {} } },
26
+ schema: { definitions: {}, root: {} },
27
+ platform: "test",
28
+ cloudProvider: "test",
29
+ });
30
+ const ensureSetup = createNextSetup({
31
+ blocksDir: false,
32
+ blocks: { myBlock: { __resolveType: "site/sections/Hero.tsx" } },
33
+ sections: { "./sections/Hero.tsx": async () => ({ default: () => null }) },
34
+ meta,
35
+ });
36
+
37
+ await ensureSetup();
38
+ await ensureSetup(); // memoized — meta loader must run once
39
+
40
+ expect(meta).toHaveBeenCalledTimes(1);
41
+ expect(loadBlocks().myBlock).toBeDefined();
42
+ expect(listRegisteredSections()).toContain("site/sections/Hero.tsx");
43
+ });
44
+
45
+ it("applies section conventions when provided", async () => {
46
+ const ensureSetup = createNextSetup({
47
+ blocksDir: false,
48
+ sections: { "./sections/Footer.tsx": async () => ({ default: () => null }) },
49
+ conventions: { meta: { "site/sections/Footer.tsx": { layout: true } } },
50
+ });
51
+ await ensureSetup();
52
+ const { isLayoutSection } = await import("@decocms/blocks/cms");
53
+ expect(isLayoutSection("site/sections/Footer.tsx")).toBe(true);
54
+ });
55
+
56
+ it("clears the memo on a rejected bootstrap so the next call retries", async () => {
57
+ const meta = vi
58
+ .fn()
59
+ .mockRejectedValueOnce(new Error("transient fetch failure"))
60
+ .mockResolvedValueOnce({
61
+ major: 1,
62
+ version: "test",
63
+ namespace: "site",
64
+ site: "test",
65
+ manifest: { blocks: { sections: {} } },
66
+ schema: { definitions: {}, root: {} },
67
+ platform: "test",
68
+ cloudProvider: "test",
69
+ });
70
+ const ensureSetup = createNextSetup({
71
+ blocksDir: false,
72
+ sections: { "./sections/Hero.tsx": async () => ({ default: () => null }) },
73
+ meta,
74
+ });
75
+
76
+ await expect(ensureSetup()).rejects.toThrow("transient fetch failure");
77
+ await expect(ensureSetup()).resolves.toBeUndefined();
78
+ expect(meta).toHaveBeenCalledTimes(2);
79
+ });
80
+ });
package/src/setup.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * One-call site bootstrap for Next.js — the App Router sibling of the
3
+ * Vite flow (`createSiteSetup` + `createAdminSetup` + import.meta.glob).
4
+ * Next has no import.meta.glob and no Vite plugin, so this composes the
5
+ * same framework pieces from a generated section registry
6
+ * (`generate-sections --registry`) and a filesystem decofile directory.
7
+ *
8
+ * ROUTE-HANDLER-SAFE: this module (and everything it imports eagerly) must
9
+ * never reach module-scope client-React — it is imported by route files
10
+ * via the site's setup module. Admin setters are imported lazily for the
11
+ * same reason createAdminSetup keeps meta lazy: they're only needed when
12
+ * an admin request actually arrives... and because @decocms/blocks-admin
13
+ * is a heavier graph than the CMS core.
14
+ *
15
+ * @example site's `src/deco/setup.ts`
16
+ * ```ts
17
+ * import { createNextSetup } from "@decocms/nextjs/setup";
18
+ * // Generators default to `.deco/`; `deco/*` is a tsconfig path alias for
19
+ * // `.deco/*` (see the package README) since `src/deco/setup.ts` isn't
20
+ * // adjacent to the site-root `.deco/` directory.
21
+ * import { sectionImports, sectionMeta, syncComponents } from "deco/sections.gen";
22
+ *
23
+ * export const ensureSetup = createNextSetup({
24
+ * sections: sectionImports,
25
+ * conventions: { meta: sectionMeta, syncComponents },
26
+ * meta: () => import("deco/meta.gen.json").then((m) => m.default),
27
+ * });
28
+ * ```
29
+ */
30
+ import type { ApplySectionConventionsInput } from "@decocms/blocks/cms";
31
+ import { applySectionConventions, loadBlocks } from "@decocms/blocks/cms";
32
+ import { loadDecofileDirectory } from "@decocms/blocks/cms/loadDecofileDirectory";
33
+ import { createSiteSetup, type SiteSetupOptions } from "@decocms/blocks/setup";
34
+
35
+ export interface NextSetupOptions {
36
+ /**
37
+ * Directory of decofile JSON snapshots, relative to the site root.
38
+ * Pass `false` to skip filesystem loading (blocks come from `blocks`).
39
+ * @default ".deco/blocks"
40
+ */
41
+ blocksDir?: string | false;
42
+
43
+ /** Extra/override blocks, merged OVER the directory's blocks. */
44
+ blocks?: Record<string, unknown>;
45
+
46
+ /**
47
+ * Lazy section registry — `sectionImports` from
48
+ * `generate-sections --registry` (keys `./sections/X.tsx`).
49
+ */
50
+ sections: Record<string, () => Promise<any>>;
51
+
52
+ /** `{ meta: sectionMeta, syncComponents, loadingFallbacks }` from sections.gen.ts (`.deco/sections.gen.ts` by default). */
53
+ conventions?: Omit<ApplySectionConventionsInput, "sectionGlob">;
54
+
55
+ /** Lazy admin meta schema: `() => import("deco/meta.gen.json").then(m => m.default)` (`.deco/meta.gen.json` by default). */
56
+ meta?: () => Promise<unknown>;
57
+
58
+ /** Admin preview shell (CSS/font URLs) — see blocks-admin setRenderShell. */
59
+ renderShell?: { css?: string; fonts?: string[] };
60
+
61
+ /** Admin preview wrapper component. */
62
+ previewWrapper?: React.ComponentType<any>;
63
+
64
+ productionOrigins?: SiteSetupOptions["productionOrigins"];
65
+ customMatchers?: SiteSetupOptions["customMatchers"];
66
+ onResolveError?: SiteSetupOptions["onResolveError"];
67
+ onDanglingReference?: SiteSetupOptions["onDanglingReference"];
68
+
69
+ /**
70
+ * Site-specific wiring that must run after the core setup (section
71
+ * loaders, SEO keys for legacy decofiles, curated post-processing).
72
+ * Receives the loaded blocks.
73
+ */
74
+ extend?: (blocks: Record<string, unknown>) => void | Promise<void>;
75
+ }
76
+
77
+ /**
78
+ * Returns a memoized `ensureSetup` function. A successful bootstrap is
79
+ * cached for the lifetime of the module (warm serverless instance); a
80
+ * *rejected* bootstrap is NOT cached — the memo is cleared on failure so
81
+ * the next call retries from scratch, while the triggering call still
82
+ * rejects with the original error.
83
+ */
84
+ export function createNextSetup(options: NextSetupOptions): () => Promise<void> {
85
+ let setupPromise: Promise<void> | null = null;
86
+
87
+ return function ensureSetup(): Promise<void> {
88
+ setupPromise ??= (async () => {
89
+ const dirBlocks =
90
+ options.blocksDir === false
91
+ ? {}
92
+ : await loadDecofileDirectory(options.blocksDir ?? ".deco/blocks");
93
+ const blocks = { ...dirBlocks, ...options.blocks };
94
+
95
+ createSiteSetup({
96
+ sections: options.sections,
97
+ blocks,
98
+ productionOrigins: options.productionOrigins,
99
+ customMatchers: options.customMatchers,
100
+ onResolveError: options.onResolveError,
101
+ onDanglingReference: options.onDanglingReference,
102
+ });
103
+
104
+ if (options.conventions) {
105
+ applySectionConventions({
106
+ ...options.conventions,
107
+ sectionGlob: options.sections,
108
+ });
109
+ }
110
+
111
+ if (options.meta || options.renderShell || options.previewWrapper) {
112
+ const admin = await import("@decocms/blocks-admin");
113
+ if (options.meta) admin.setMetaData((await options.meta()) as never);
114
+ if (options.renderShell) admin.setRenderShell(options.renderShell);
115
+ if (options.previewWrapper) admin.setPreviewWrapper(options.previewWrapper);
116
+ }
117
+
118
+ await options.extend?.(loadBlocks());
119
+ })().catch((error) => {
120
+ // A failed bootstrap must not poison the warm instance: clear the memo
121
+ // so the next request retries (transient fs/fetch failures are the
122
+ // common case in serverless cold starts). The error still propagates
123
+ // to THIS caller so the triggering request fails loudly.
124
+ setupPromise = null;
125
+ throw error;
126
+ });
127
+ return setupPromise;
128
+ };
129
+ }