/** * One-call site bootstrap for Next.js — the App Router sibling of the * Vite flow (`createSiteSetup` + `createAdminSetup` + import.meta.glob). * Next has no import.meta.glob and no Vite plugin, so this composes the * same framework pieces from a generated section registry * (`generate-sections --registry`) and a filesystem decofile directory. * * ROUTE-HANDLER-SAFE: this module (and everything it imports eagerly) must * never reach module-scope client-React — it is imported by route files * via the site's setup module. Admin setters are imported lazily for the * same reason createAdminSetup keeps meta lazy: they're only needed when * an admin request actually arrives... and because @decocms/blocks-admin * is a heavier graph than the CMS core. * * @example site's `src/deco/setup.ts` — recommended wiring: the static-import * blocks manifest (`generate-blocks-manifest`) as the block source, so the * bundler's module graph owns CMS content (dev hot-reload on block edits, no * `outputFileTracingIncludes` for `.deco/blocks` in deploys). * ```ts * import { createNextSetup } from "@decocms/nextjs/setup"; * // Generators default to `.deco/`; `deco/*` is a tsconfig path alias for * // `.deco/*` (see the package README) since `src/deco/setup.ts` isn't * // adjacent to the site-root `.deco/` directory. * import blocks from "deco/blocksManifest.gen"; * import { sectionImports, sectionMeta, syncComponents } from "deco/sections.gen"; * * export const ensureSetup = createNextSetup({ * blocks, * blocksDir: false, // manifest IS the directory — skip the runtime fs read * sections: sectionImports, * conventions: { meta: sectionMeta, syncComponents }, * meta: () => import("deco/meta.gen.json").then((m) => m.default), * }); * ``` * With `blocksDir: false` + `blocks`, this bootstrap is pure — no filesystem * access. Sites that skip the manifest can instead rely on the default * `blocksDir: ".deco/blocks"` runtime read (no dev reload on content edits; * deploys must ship the directory alongside the server bundle). */ import type { ApplySectionConventionsInput } from "@decocms/blocks/cms"; import { applySectionConventions, loadBlocks, setDraftPreviewHosts } from "@decocms/blocks/cms"; import { loadDecofileDirectory } from "@decocms/blocks/cms/loadDecofileDirectory"; import { createSiteSetup, type SiteSetupOptions } from "@decocms/blocks/setup"; export interface NextSetupOptions { /** * Directory of decofile JSON snapshots, relative to the site root, read * at bootstrap time with a plain fs scan. Pass `false` to skip filesystem * loading entirely (blocks come from `blocks`) — with `blocks` set this * makes the bootstrap pure, which is the recommended manifest wiring (see * the module-level example). * @default ".deco/blocks" */ blocksDir?: string | false; /** * Extra/override blocks, merged OVER the directory's blocks. Pass the * default export of a generated static-import manifest * (`generate-blocks-manifest` → `.deco/blocksManifest.gen.ts`) together * with `blocksDir: false` to make the manifest the sole block source. */ blocks?: Record; /** * Lazy section registry — `sectionImports` from * `generate-sections --registry` (keys `./sections/X.tsx`). */ sections: Record Promise>; /** `{ meta: sectionMeta, syncComponents, loadingFallbacks }` from sections.gen.ts (`.deco/sections.gen.ts` by default). */ conventions?: Omit; /** Lazy admin meta schema: `() => import("deco/meta.gen.json").then(m => m.default)` (`.deco/meta.gen.json` by default). */ meta?: () => Promise; /** Admin preview shell (CSS/font URLs) — see blocks-admin setRenderShell. */ renderShell?: { css?: string; fonts?: string[] }; /** Admin preview wrapper component. */ previewWrapper?: React.ComponentType; productionOrigins?: SiteSetupOptions["productionOrigins"]; customMatchers?: SiteSetupOptions["customMatchers"]; onResolveError?: SiteSetupOptions["onResolveError"]; onDanglingReference?: SiteSetupOptions["onDanglingReference"]; /** * Site-specific wiring that must run after the core setup (section * loaders, SEO keys for legacy decofiles, curated post-processing). * Receives the loaded blocks. */ extend?: (blocks: Record) => void | Promise; } /** * Returns a memoized `ensureSetup` function. A successful bootstrap is * cached for the lifetime of the module (warm serverless instance); a * *rejected* bootstrap is NOT cached — the memo is cleared on failure so * the next call retries from scratch, while the triggering call still * rejects with the original error. * * "Lifetime of the module" is exactly what makes the static-import manifest * hot-reload in `next dev`: the site's setup module imports * `blocksManifest.gen.ts`, which statically imports every block JSON, so * editing one invalidates the server module graph up through the setup * module. Next re-evaluates it, `createNextSetup` runs again with the fresh * JSON, and this memo is rebuilt from scratch — that module-graph reset IS * the designed dev reload mechanism for CMS content (measured at ~120–165ms * per edit on a 500-block site). With the default `blocksDir` fs read * instead, nothing imports the JSON, so edits invalidate nothing and dev * serves stale content until a full restart. */ /** * Install the site block's `previewHosts` as the draft-preview allowlist. * * Reads the BASE blocks handed to setup — never `loadBlocks()` at request * time, where the draft override is merged in: an allowlist readable through * the override could be rewritten by the very draft it gates. * `DECO_ALLOWED_PREVIEW_HOSTS` remains an operational override that replaces * this list when set. */ function installPreviewHosts(blocks: Record | undefined): void { const site = blocks?.site as { previewHosts?: unknown } | undefined; if (Array.isArray(site?.previewHosts)) setDraftPreviewHosts(site.previewHosts); } export function createNextSetup(options: NextSetupOptions): () => Promise { let setupPromise: Promise | null = null; // Draft preview opt-in from the repo: read SYNCHRONOUSLY at createNextSetup // time (module evaluation), not inside the lazy ensureSetup — pages call // `ensureDraft` BEFORE they resolve CMS content, so hosts installed lazily // would arrive after the gate already said no on the first request. installPreviewHosts(options.blocks); return function ensureSetup(): Promise { setupPromise ??= (async () => { const dirBlocks = options.blocksDir === false ? {} : await loadDecofileDirectory(options.blocksDir ?? ".deco/blocks"); const blocks = { ...dirBlocks, ...options.blocks }; // Covers the blocksDir mode, where blocks only exist after the fs read. installPreviewHosts(blocks); createSiteSetup({ sections: options.sections, blocks, productionOrigins: options.productionOrigins, customMatchers: options.customMatchers, onResolveError: options.onResolveError, onDanglingReference: options.onDanglingReference, }); if (options.conventions) { applySectionConventions({ ...options.conventions, sectionGlob: options.sections, }); } if (options.meta || options.renderShell || options.previewWrapper) { const admin = await import("@decocms/blocks-admin"); if (options.meta) admin.setMetaData((await options.meta()) as never); if (options.renderShell) admin.setRenderShell(options.renderShell); if (options.previewWrapper) admin.setPreviewWrapper(options.previewWrapper); } await options.extend?.(loadBlocks()); })().catch((error) => { // A failed bootstrap must not poison the warm instance: clear the memo // so the next request retries (transient fs/fetch failures are the // common case in serverless cold starts). The error still propagates // to THIS caller so the triggering request fails loudly. setupPromise = null; throw error; }); return setupPromise; }; }