@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 +270 -0
- package/package.json +9 -4
- package/src/config.cjs +37 -0
- package/src/config.d.cts +4 -0
- package/src/config.test.ts +47 -0
- package/src/routeHandlers.test.ts +144 -2
- package/src/routeHandlers.ts +120 -0
- package/src/setup.test.ts +80 -0
- package/src/setup.ts +129 -0
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
|
+
"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.
|
|
24
|
-
"@decocms/blocks-admin": "7.
|
|
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 };
|
package/src/config.d.cts
ADDED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/src/routeHandlers.ts
CHANGED
|
@@ -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
|
+
}
|