@decocms/blocks-admin 7.0.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/package.json +39 -0
- package/src/admin/cors.ts +49 -0
- package/src/admin/decofile.test.ts +146 -0
- package/src/admin/decofile.ts +189 -0
- package/src/admin/index.ts +29 -0
- package/src/admin/invoke.test.ts +141 -0
- package/src/admin/invoke.ts +290 -0
- package/src/admin/liveControls.ts +29 -0
- package/src/admin/meta.ts +91 -0
- package/src/admin/render.ts +289 -0
- package/src/admin/setup.ts +85 -0
- package/src/apps/autoconfig.ts +135 -0
- package/src/apps/index.ts +24 -0
- package/src/createAdminSetup.ts +64 -0
- package/src/sdk/htmlShell.ts +55 -0
- package/src/sdk/setupApps.ts +263 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-safe admin setup functions.
|
|
3
|
+
*
|
|
4
|
+
* These functions only set module-level state (no node: imports,
|
|
5
|
+
* no AsyncLocalStorage). Safe to import in both client and SSR builds.
|
|
6
|
+
*
|
|
7
|
+
* For server-only handlers (handleMeta, handleRender, etc.),
|
|
8
|
+
* import from "@decocms/start/admin" instead.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
type InvokeAction,
|
|
13
|
+
type InvokeLoader,
|
|
14
|
+
registerInvokeHandlers,
|
|
15
|
+
setInvokeActions,
|
|
16
|
+
setInvokeLoaders,
|
|
17
|
+
} from "./invoke";
|
|
18
|
+
export { setMetaData } from "./meta";
|
|
19
|
+
export {
|
|
20
|
+
type ActionConfig,
|
|
21
|
+
type LoaderConfig,
|
|
22
|
+
type MatcherConfig,
|
|
23
|
+
registerActionSchema,
|
|
24
|
+
registerActionSchemas,
|
|
25
|
+
registerLoaderSchema,
|
|
26
|
+
registerLoaderSchemas,
|
|
27
|
+
registerMatcherSchema,
|
|
28
|
+
registerMatcherSchemas,
|
|
29
|
+
} from "@decocms/blocks/cms";
|
|
30
|
+
|
|
31
|
+
let cssHref: string | null = null;
|
|
32
|
+
let fontHrefs: string[] = [];
|
|
33
|
+
let themeName = "";
|
|
34
|
+
let bodyClass = "";
|
|
35
|
+
let htmlLang = "en";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Optional React component that wraps section renders in admin previews.
|
|
39
|
+
* Use this to provide context that sections depend on (Router, QueryClient, etc.)
|
|
40
|
+
* without which renderToString would crash.
|
|
41
|
+
*
|
|
42
|
+
* The wrapper receives `{ children }` and should render them inside the
|
|
43
|
+
* necessary providers.
|
|
44
|
+
*/
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
46
|
+
let previewWrapperComponent: any = null;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Register a wrapper component for admin preview renders.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* // In site's setup.ts:
|
|
54
|
+
* import { setPreviewWrapper } from "@decocms/start/admin/setup";
|
|
55
|
+
* import { PreviewProviders } from "./components/PreviewProviders";
|
|
56
|
+
* setPreviewWrapper(PreviewProviders);
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
60
|
+
export function setPreviewWrapper(wrapper: any) {
|
|
61
|
+
previewWrapperComponent = wrapper;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
65
|
+
export function getPreviewWrapper(): any {
|
|
66
|
+
return previewWrapperComponent;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function setRenderShell(opts: {
|
|
70
|
+
css?: string;
|
|
71
|
+
fonts?: string[];
|
|
72
|
+
theme?: string;
|
|
73
|
+
bodyClass?: string;
|
|
74
|
+
lang?: string;
|
|
75
|
+
}) {
|
|
76
|
+
if (opts.css) cssHref = opts.css;
|
|
77
|
+
if (opts.fonts) fontHrefs = opts.fonts;
|
|
78
|
+
if (opts.theme !== undefined) themeName = opts.theme;
|
|
79
|
+
if (opts.bodyClass !== undefined) bodyClass = opts.bodyClass;
|
|
80
|
+
if (opts.lang !== undefined) htmlLang = opts.lang;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getRenderShellConfig() {
|
|
84
|
+
return { cssHref, fontHrefs, themeName, bodyClass, htmlLang };
|
|
85
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry-driven auto-configuration of known apps from CMS blocks.
|
|
3
|
+
*
|
|
4
|
+
* A site passes an `AppRegistry` (declarative list of known app blockKeys +
|
|
5
|
+
* lazy module imports). For every registry entry whose `blockKey` exists in
|
|
6
|
+
* the decofile, this calls `mod.configure(block, resolveSecret)` and then
|
|
7
|
+
* hands the resulting AppDefinitions off to `setupApps()`.
|
|
8
|
+
*
|
|
9
|
+
* The canonical registry lives in `@decocms/apps/registry` so the framework
|
|
10
|
+
* itself has zero knowledge of which apps exist.
|
|
11
|
+
*
|
|
12
|
+
* Usage in setup.ts:
|
|
13
|
+
* import { autoconfigApps } from "@decocms/start/apps/autoconfig";
|
|
14
|
+
* import { APP_REGISTRY } from "@decocms/apps/registry";
|
|
15
|
+
* await autoconfigApps(generatedBlocks, APP_REGISTRY);
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { onChange } from "@decocms/blocks/cms";
|
|
19
|
+
import { resolveSecret } from "@decocms/blocks/sdk/crypto";
|
|
20
|
+
import {
|
|
21
|
+
setupApps,
|
|
22
|
+
type AppDefinition,
|
|
23
|
+
type AppDefinitionWithHandlers,
|
|
24
|
+
} from "../sdk/setupApps";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Shape of the secret resolver passed to each app's `configure()`. Matches
|
|
28
|
+
* `resolveSecret` in `src/sdk/crypto.ts`. Apps typically narrow the return
|
|
29
|
+
* type inside their own `configure()` by throwing on null.
|
|
30
|
+
*/
|
|
31
|
+
export type ResolveSecret = (
|
|
32
|
+
value: unknown,
|
|
33
|
+
envVarName?: string,
|
|
34
|
+
) => Promise<string | null>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One entry in the app registry — describes a single installable app.
|
|
38
|
+
* Sites pass an array of these to `autoconfigApps()` (typically imported
|
|
39
|
+
* from `@decocms/apps/registry`).
|
|
40
|
+
*/
|
|
41
|
+
export interface AppRegistryEntry {
|
|
42
|
+
/** Block key in the decofile, e.g. "deco-shopify". */
|
|
43
|
+
blockKey: string;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Lazy import of the app's mod module. Must return an object exposing
|
|
47
|
+
* `configure(block, resolveSecret)` and optionally `handlers`.
|
|
48
|
+
*
|
|
49
|
+
* Use a string-literal dynamic import so bundlers (Vite/Rollup) can
|
|
50
|
+
* statically trace the chunk. E.g.
|
|
51
|
+
* () => import("@decocms/apps/shopify/mod")
|
|
52
|
+
*/
|
|
53
|
+
module: () => Promise<{
|
|
54
|
+
configure: (
|
|
55
|
+
block: unknown,
|
|
56
|
+
resolveSecret: ResolveSecret,
|
|
57
|
+
) => Promise<AppDefinition | null>;
|
|
58
|
+
handlers?: Record<string, (props: any, req: Request) => Promise<any>>;
|
|
59
|
+
}>;
|
|
60
|
+
|
|
61
|
+
/** Human-readable name shown in admin install UI. */
|
|
62
|
+
displayName?: string;
|
|
63
|
+
/** Icon URL (absolute or site-relative) shown in admin install UI. */
|
|
64
|
+
icon?: string;
|
|
65
|
+
/** Grouping label, e.g. "commerce", "email", "analytics". */
|
|
66
|
+
category?: string;
|
|
67
|
+
/** Short summary (one sentence) shown in admin install UI. */
|
|
68
|
+
description?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type AppRegistry = readonly AppRegistryEntry[];
|
|
72
|
+
|
|
73
|
+
async function configureAllApps(
|
|
74
|
+
blocks: Record<string, unknown>,
|
|
75
|
+
registry: AppRegistry,
|
|
76
|
+
): Promise<AppDefinitionWithHandlers[]> {
|
|
77
|
+
const apps: AppDefinitionWithHandlers[] = [];
|
|
78
|
+
|
|
79
|
+
for (const entry of registry) {
|
|
80
|
+
const block = blocks[entry.blockKey];
|
|
81
|
+
if (!block) continue;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const mod = await entry.module();
|
|
85
|
+
if (typeof mod?.configure !== "function") continue;
|
|
86
|
+
|
|
87
|
+
const appDef: AppDefinition | null = await mod.configure(
|
|
88
|
+
block,
|
|
89
|
+
resolveSecret,
|
|
90
|
+
);
|
|
91
|
+
if (!appDef) continue;
|
|
92
|
+
|
|
93
|
+
const withHandlers: AppDefinitionWithHandlers = {
|
|
94
|
+
...appDef,
|
|
95
|
+
handlers: mod.handlers,
|
|
96
|
+
};
|
|
97
|
+
apps.push(withHandlers);
|
|
98
|
+
} catch {
|
|
99
|
+
// App module missing, configure threw, or block was malformed — skip.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return apps;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Auto-configure apps from CMS blocks against a declarative registry.
|
|
108
|
+
*
|
|
109
|
+
* Call once in setup.ts after setBlocks(). Re-runs on admin hot-reload.
|
|
110
|
+
*
|
|
111
|
+
* @param blocks Decofile blocks (from blocks.gen or loadBlocks()).
|
|
112
|
+
* @param registry List of installable apps — typically
|
|
113
|
+
* `import { APP_REGISTRY } from "@decocms/apps/registry"`.
|
|
114
|
+
*/
|
|
115
|
+
export async function autoconfigApps(
|
|
116
|
+
blocks: Record<string, unknown>,
|
|
117
|
+
registry: AppRegistry,
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
if (typeof document !== "undefined") return; // server-only
|
|
120
|
+
if (!registry || registry.length === 0) return;
|
|
121
|
+
|
|
122
|
+
const apps = await configureAllApps(blocks, registry);
|
|
123
|
+
if (apps.length > 0) {
|
|
124
|
+
await setupApps(apps);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Re-configure on admin hot-reload
|
|
128
|
+
onChange(async (newBlocks) => {
|
|
129
|
+
if (typeof document !== "undefined") return;
|
|
130
|
+
const updatedApps = await configureAllApps(newBlocks, registry);
|
|
131
|
+
if (updatedApps.length > 0) {
|
|
132
|
+
await setupApps(updatedApps);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public entry point for app-install primitives.
|
|
3
|
+
*
|
|
4
|
+
* Sites import from `@decocms/start/apps`:
|
|
5
|
+
* - `autoconfigApps(blocks, registry)` — main bootstrap call
|
|
6
|
+
* - `AppRegistry`, `AppRegistryEntry` — registry types
|
|
7
|
+
* - `setupApps`, `AppDefinition` — lower-level primitives when composing custom registries
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
autoconfigApps,
|
|
12
|
+
type AppRegistry,
|
|
13
|
+
type AppRegistryEntry,
|
|
14
|
+
} from "./autoconfig";
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
setupApps,
|
|
18
|
+
registerAppMiddleware,
|
|
19
|
+
getAppMiddleware,
|
|
20
|
+
type AppDefinition,
|
|
21
|
+
type AppDefinitionWithHandlers,
|
|
22
|
+
type AppManifest,
|
|
23
|
+
type AppMiddleware,
|
|
24
|
+
} from "../sdk/setupApps";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The admin-facing half of a site's one-call bootstrap: admin meta schema,
|
|
3
|
+
* render shell config, preview wrapper, and commerce-loaders-to-invoke
|
|
4
|
+
* wiring. Call alongside createSiteSetup() (@decocms/blocks/setup)
|
|
5
|
+
* — the two were one function before the package split; they're separate
|
|
6
|
+
* here because these four steps need admin/index.ts's setters, which
|
|
7
|
+
* runtime cannot import without creating a circular dependency.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
setInvokeLoaders,
|
|
11
|
+
setMetaData,
|
|
12
|
+
setPreviewWrapper,
|
|
13
|
+
setRenderShell,
|
|
14
|
+
} from "./admin/index";
|
|
15
|
+
|
|
16
|
+
export interface AdminSetupOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Lazy loader for admin meta schema — only fetched when admin requests it:
|
|
19
|
+
* `() => import("./server/admin/meta.gen.json").then(m => m.default)`
|
|
20
|
+
*/
|
|
21
|
+
meta: () => Promise<any>;
|
|
22
|
+
|
|
23
|
+
/** CSS file URL from Vite `?url` import. */
|
|
24
|
+
css: string;
|
|
25
|
+
|
|
26
|
+
/** Font URLs to preload in admin preview shell. */
|
|
27
|
+
fonts?: string[];
|
|
28
|
+
|
|
29
|
+
/** Preview wrapper component for admin preview iframe. */
|
|
30
|
+
previewWrapper?: React.ComponentType<any>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Commerce loaders getter — passed to `setInvokeLoaders`.
|
|
34
|
+
* Use a thunk so the full map (including site-specific loaders
|
|
35
|
+
* defined after createAdminSetup) is captured.
|
|
36
|
+
*/
|
|
37
|
+
getCommerceLoaders?: () => Record<string, (props: any, request?: Request) => Promise<any>>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Bootstrap a Deco site's admin protocol — meta schema, render shell,
|
|
42
|
+
* preview wrapper, commerce-loader-to-invoke wiring. Call alongside
|
|
43
|
+
* createSiteSetup() (@decocms/blocks/setup).
|
|
44
|
+
*/
|
|
45
|
+
export function createAdminSetup(options: AdminSetupOptions): void {
|
|
46
|
+
// 7. Admin meta schema (lazy)
|
|
47
|
+
options.meta().then((data) => setMetaData(data));
|
|
48
|
+
|
|
49
|
+
// 8. Render shell
|
|
50
|
+
setRenderShell({
|
|
51
|
+
css: options.css,
|
|
52
|
+
fonts: options.fonts,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// 9. Preview wrapper
|
|
56
|
+
if (options.previewWrapper) {
|
|
57
|
+
setPreviewWrapper(options.previewWrapper);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 10. Commerce loaders → invoke
|
|
61
|
+
if (options.getCommerceLoaders) {
|
|
62
|
+
setInvokeLoaders(options.getCommerceLoaders);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTML shell builder for admin preview iframes and render endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Both `workerEntry.ts` (preview shell) and `admin/render.ts` (section render)
|
|
5
|
+
* need to produce an HTML document with the site's CSS, fonts, and theme.
|
|
6
|
+
* This module provides a single implementation to keep them consistent.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getRenderShellConfig } from "../admin/setup";
|
|
10
|
+
|
|
11
|
+
export interface HtmlShellOptions {
|
|
12
|
+
/** Content to inject into <body>. */
|
|
13
|
+
body?: string;
|
|
14
|
+
/** Inline <script> content to inject in <head>. */
|
|
15
|
+
script?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build a complete HTML shell using the current render config
|
|
20
|
+
* (set via `setRenderShell()` in the site's setup.ts).
|
|
21
|
+
*/
|
|
22
|
+
export function buildHtmlShell(options: HtmlShellOptions = {}): string {
|
|
23
|
+
const { cssHref, fontHrefs, themeName, bodyClass, htmlLang } = getRenderShellConfig();
|
|
24
|
+
|
|
25
|
+
const themeAttr = themeName ? ` data-theme="${themeName}"` : "";
|
|
26
|
+
const langAttr = htmlLang ? ` lang="${htmlLang}"` : "";
|
|
27
|
+
const bodyAttr = bodyClass ? ` class="${bodyClass}"` : "";
|
|
28
|
+
|
|
29
|
+
const stylesheets = [
|
|
30
|
+
...fontHrefs.map((href) => `<link rel="stylesheet" href="${href}" />`),
|
|
31
|
+
cssHref ? `<link rel="stylesheet" href="${cssHref}" />` : "",
|
|
32
|
+
]
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.join("\n ");
|
|
35
|
+
|
|
36
|
+
const scriptTag = options.script ? `<script>${options.script}</script>` : "";
|
|
37
|
+
|
|
38
|
+
const bodyContent = options.body ?? `<div id="preview-root" style="display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:system-ui;color:#666;">
|
|
39
|
+
Loading preview...
|
|
40
|
+
</div>`;
|
|
41
|
+
|
|
42
|
+
return `<!DOCTYPE html>
|
|
43
|
+
<html${langAttr}${themeAttr}>
|
|
44
|
+
<head>
|
|
45
|
+
<meta charset="utf-8" />
|
|
46
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
47
|
+
<title>Preview</title>
|
|
48
|
+
${stylesheets}
|
|
49
|
+
${scriptTag}
|
|
50
|
+
</head>
|
|
51
|
+
<body${bodyAttr}>
|
|
52
|
+
${bodyContent}
|
|
53
|
+
</body>
|
|
54
|
+
</html>`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App system integration pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Consumes AppDefinition objects from @decocms/apps and automates:
|
|
5
|
+
* 1. Invoke handler registration (from manifest + explicit handlers)
|
|
6
|
+
* 2. Section registration (when manifest.sections is available)
|
|
7
|
+
* 3. App middleware registration (with state injection into RequestContext)
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { setupApps } from "@decocms/start/sdk/setupApps";
|
|
12
|
+
* import * as vtexApp from "@decocms/apps/vtex/mod";
|
|
13
|
+
* import * as resendApp from "@decocms/apps/resend/mod";
|
|
14
|
+
*
|
|
15
|
+
* const vtex = await vtexApp.configure(blocks["deco-vtex"], resolveSecret);
|
|
16
|
+
* const resend = await resendApp.configure(blocks["deco-resend"], resolveSecret);
|
|
17
|
+
*
|
|
18
|
+
* await setupApps([vtex, resend].filter(Boolean));
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { clearInvokeHandlers, registerInvokeHandlers } from "../admin/invoke";
|
|
23
|
+
import {
|
|
24
|
+
registerSections,
|
|
25
|
+
registerCommerceLoaders,
|
|
26
|
+
unregisterCommerceLoader,
|
|
27
|
+
} from "@decocms/blocks/cms";
|
|
28
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Types — mirrors @decocms/apps/commerce/app-types without importing it
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
export interface AppManifest {
|
|
35
|
+
name: string;
|
|
36
|
+
loaders: Record<string, Record<string, unknown>>;
|
|
37
|
+
actions: Record<string, Record<string, unknown>>;
|
|
38
|
+
sections?: Record<string, () => Promise<any>>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AppMiddleware {
|
|
42
|
+
(request: Request, next: () => Promise<Response>): Promise<Response>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AppDefinition<TState = unknown> {
|
|
46
|
+
name: string;
|
|
47
|
+
manifest: AppManifest;
|
|
48
|
+
state: TState;
|
|
49
|
+
middleware?: AppMiddleware;
|
|
50
|
+
dependencies?: AppDefinition[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extended definition with optional explicit handlers.
|
|
55
|
+
* autoconfigApps() attaches mod.handlers here before calling setupApps().
|
|
56
|
+
*/
|
|
57
|
+
export interface AppDefinitionWithHandlers<TState = unknown>
|
|
58
|
+
extends AppDefinition<TState> {
|
|
59
|
+
/** Pre-wrapped handlers from the app's mod.ts (e.g. unwrapped VTEX actions). */
|
|
60
|
+
handlers?: Record<string, (props: any, request: Request) => Promise<any>>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// App middleware registry
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/** Per-app state entries — injected into RequestContext.bag on every request. */
|
|
68
|
+
const appStates: Array<{ name: string; state: unknown }> = [];
|
|
69
|
+
|
|
70
|
+
const appMiddlewares: Array<{
|
|
71
|
+
name: string;
|
|
72
|
+
middleware: AppMiddleware;
|
|
73
|
+
}> = [];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Keys this module wrote into the commerce-loaders map. Tracked so hot-reload
|
|
77
|
+
* can wipe app-owned entries without touching site-local registrations.
|
|
78
|
+
*/
|
|
79
|
+
const appCommerceLoaderKeys = new Set<string>();
|
|
80
|
+
|
|
81
|
+
function clearAppCommerceLoaders() {
|
|
82
|
+
for (const key of appCommerceLoaderKeys) unregisterCommerceLoader(key);
|
|
83
|
+
appCommerceLoaderKeys.clear();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function registerAppState(name: string, state: unknown) {
|
|
87
|
+
appStates.push({ name, state });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function registerAppMiddleware(
|
|
91
|
+
name: string,
|
|
92
|
+
mw: AppMiddleware,
|
|
93
|
+
) {
|
|
94
|
+
appMiddlewares.push({ name, middleware: mw });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Clear all registrations. Called before re-running setupApps()
|
|
99
|
+
* on admin hot-reload to prevent duplicate middleware/state entries.
|
|
100
|
+
*/
|
|
101
|
+
function clearRegistrations() {
|
|
102
|
+
appStates.length = 0;
|
|
103
|
+
appMiddlewares.length = 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Returns a chained middleware that runs all registered app middlewares.
|
|
108
|
+
* The site wires this into its own createMiddleware() chain.
|
|
109
|
+
*
|
|
110
|
+
* Before running app middlewares, all app states are injected into
|
|
111
|
+
* RequestContext.bag so loaders can access them via getAppState().
|
|
112
|
+
*
|
|
113
|
+
* Returns undefined if no app states or middlewares were registered.
|
|
114
|
+
*/
|
|
115
|
+
export function getAppMiddleware(): AppMiddleware | undefined {
|
|
116
|
+
if (appStates.length === 0 && appMiddlewares.length === 0) return undefined;
|
|
117
|
+
|
|
118
|
+
return async (request, next) => {
|
|
119
|
+
// Inject all app states into RequestContext bag
|
|
120
|
+
for (const { name, state } of appStates) {
|
|
121
|
+
RequestContext.setBag(`app:${name}:state`, state);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Chain app middlewares (first registered runs outermost)
|
|
125
|
+
if (appMiddlewares.length === 0) return next();
|
|
126
|
+
const run = async (i: number): Promise<Response> => {
|
|
127
|
+
if (i >= appMiddlewares.length) return next();
|
|
128
|
+
return appMiddlewares[i].middleware(request, () => run(i + 1));
|
|
129
|
+
};
|
|
130
|
+
return run(0);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Dependency flattening
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Topological sort: dependencies before parents.
|
|
140
|
+
* Combined with first-wins registration in registerInvokeHandlers,
|
|
141
|
+
* this means parent apps can override handlers from their dependencies
|
|
142
|
+
* by providing explicit `handlers` (registered before manifest flatten).
|
|
143
|
+
*/
|
|
144
|
+
function flattenDependencies(apps: AppDefinition[]): AppDefinition[] {
|
|
145
|
+
const seen = new Set<string>();
|
|
146
|
+
const result: AppDefinition[] = [];
|
|
147
|
+
|
|
148
|
+
function visit(app: AppDefinition) {
|
|
149
|
+
if (seen.has(app.name)) return;
|
|
150
|
+
seen.add(app.name);
|
|
151
|
+
if (app.dependencies) {
|
|
152
|
+
for (const dep of app.dependencies) visit(dep);
|
|
153
|
+
}
|
|
154
|
+
result.push(app);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const app of apps) visit(app);
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Register handlers into the commerce-loaders map used by the CMS resolve path
|
|
163
|
+
* (src/cms/resolve.ts). Tracks the keys so clearAppCommerceLoaders() can revert
|
|
164
|
+
* them on hot-reload without clobbering site-registered loaders.
|
|
165
|
+
*
|
|
166
|
+
* CommerceLoader receives `(props)` only. The admin invoke path passes
|
|
167
|
+
* `(props, req)`; here `req` is undefined. Handlers that need `req` should
|
|
168
|
+
* rely on RequestContext instead of a positional argument.
|
|
169
|
+
*/
|
|
170
|
+
function registerAppCommerceHandlers(
|
|
171
|
+
handlers: Record<string, (props: any, req: Request) => Promise<any>>,
|
|
172
|
+
) {
|
|
173
|
+
const entries: Record<string, (props: any) => Promise<any>> = {};
|
|
174
|
+
for (const [key, handler] of Object.entries(handlers)) {
|
|
175
|
+
entries[key] = (props: any) => handler(props, undefined as unknown as Request);
|
|
176
|
+
appCommerceLoaderKeys.add(key);
|
|
177
|
+
}
|
|
178
|
+
registerCommerceLoaders(entries);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Main pipeline
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Initialize apps from their AppDefinitions.
|
|
187
|
+
*
|
|
188
|
+
* Call once in setup.ts after configuring apps via their mod.configure().
|
|
189
|
+
* Handles: invoke handler registration, section registration, middleware setup.
|
|
190
|
+
*/
|
|
191
|
+
export async function setupApps(
|
|
192
|
+
apps: Array<AppDefinitionWithHandlers | AppDefinition>,
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
if (typeof document !== "undefined") return; // server-only
|
|
195
|
+
|
|
196
|
+
// Clear previous registrations (safe for hot-reload via onChange)
|
|
197
|
+
clearRegistrations();
|
|
198
|
+
clearInvokeHandlers();
|
|
199
|
+
clearAppCommerceLoaders();
|
|
200
|
+
|
|
201
|
+
for (const app of flattenDependencies(apps as AppDefinition[])) {
|
|
202
|
+
const appWithHandlers = app as AppDefinitionWithHandlers;
|
|
203
|
+
|
|
204
|
+
// 1. Register explicit handlers (pre-unwrapped by the app, e.g. resend)
|
|
205
|
+
// These also go into the commerce-loaders map so the CMS resolve path
|
|
206
|
+
// (src/cms/resolve.ts) can dispatch to them by __resolveType.
|
|
207
|
+
if (appWithHandlers.handlers) {
|
|
208
|
+
registerInvokeHandlers(appWithHandlers.handlers);
|
|
209
|
+
registerAppCommerceHandlers(appWithHandlers.handlers);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 2. Flatten manifest modules → individual invoke handlers.
|
|
213
|
+
//
|
|
214
|
+
// Convention (mirrors legacy deco-cx/apps):
|
|
215
|
+
// - `default` export is registered at the moduleKey itself.
|
|
216
|
+
// shopify/loaders/ProductList.ts (default) → key "shopify/loaders/ProductList"
|
|
217
|
+
// - Named function exports are registered at `${moduleKey}/${fnName}`.
|
|
218
|
+
// vtex/actions/checkout.ts ({ getOrCreateCart, addItemsToCart })
|
|
219
|
+
// → keys "vtex/actions/checkout/getOrCreateCart", ".../addItemsToCart"
|
|
220
|
+
// - Each key also gets a `.ts` sibling for callers that include the
|
|
221
|
+
// extension (admin invoke path, some __resolveType producers).
|
|
222
|
+
for (const category of ["loaders", "actions"] as const) {
|
|
223
|
+
const modules = app.manifest[category];
|
|
224
|
+
if (!modules) continue;
|
|
225
|
+
|
|
226
|
+
for (const [moduleKey, moduleExports] of Object.entries(modules)) {
|
|
227
|
+
const exports = moduleExports as Record<string, unknown>;
|
|
228
|
+
|
|
229
|
+
for (const [fnName, fn] of Object.entries(exports)) {
|
|
230
|
+
if (typeof fn !== "function") continue;
|
|
231
|
+
const key = fnName === "default"
|
|
232
|
+
? moduleKey
|
|
233
|
+
: `${moduleKey}/${fnName}`;
|
|
234
|
+
const handler = (props: any, req: Request) =>
|
|
235
|
+
(fn as Function)(props, req);
|
|
236
|
+
registerInvokeHandlers({
|
|
237
|
+
[key]: handler,
|
|
238
|
+
[`${key}.ts`]: handler,
|
|
239
|
+
});
|
|
240
|
+
registerAppCommerceHandlers({
|
|
241
|
+
[key]: handler,
|
|
242
|
+
[`${key}.ts`]: handler,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 3. Register sections from manifest (future — when apps export sections)
|
|
249
|
+
if (app.manifest.sections) {
|
|
250
|
+
registerSections(
|
|
251
|
+
app.manifest.sections as Record<string, () => Promise<any>>,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 4. Always register app state (so getAppState() works for all apps)
|
|
256
|
+
registerAppState(app.name, app.state);
|
|
257
|
+
|
|
258
|
+
// 5. Register middleware (optional — not all apps have middleware)
|
|
259
|
+
if (app.middleware) {
|
|
260
|
+
registerAppMiddleware(app.name, app.middleware);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|