@decocms/start 0.19.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/.cursor/skills/deco-api-call-dedup/SKILL.md +443 -0
- package/.cursor/skills/deco-apps-architecture/SKILL.md +255 -0
- package/.cursor/skills/deco-apps-architecture/app-pattern.md +288 -0
- package/.cursor/skills/deco-apps-architecture/commerce-types.md +239 -0
- package/.cursor/skills/deco-apps-architecture/new-app-guide.md +268 -0
- package/.cursor/skills/deco-apps-architecture/scripts-codegen.md +148 -0
- package/.cursor/skills/deco-apps-architecture/shared-utils.md +181 -0
- package/.cursor/skills/deco-apps-architecture/vtex-deep-structure.md +253 -0
- package/.cursor/skills/deco-apps-architecture/website-app.md +169 -0
- package/.cursor/skills/deco-apps-vtex-porting/SKILL.md +189 -0
- package/.cursor/skills/deco-apps-vtex-porting/adaptation-patterns.md +335 -0
- package/.cursor/skills/deco-apps-vtex-porting/commerce-porting.md +155 -0
- package/.cursor/skills/deco-apps-vtex-porting/cookie-auth-patterns.md +148 -0
- package/.cursor/skills/deco-apps-vtex-porting/structure-map.md +234 -0
- package/.cursor/skills/deco-apps-vtex-porting/transform-mapping.md +99 -0
- package/.cursor/skills/deco-apps-vtex-porting/website-porting.md +194 -0
- package/.cursor/skills/deco-apps-vtex-review/SKILL.md +234 -0
- package/.cursor/skills/deco-async-rendering-architecture/SKILL.md +270 -0
- package/.cursor/skills/deco-async-rendering-site-guide/SKILL.md +417 -0
- package/.cursor/skills/deco-cms-layout-caching/SKILL.md +293 -0
- package/.cursor/skills/deco-cms-route-config/SKILL.md +388 -0
- package/.cursor/skills/deco-core-architecture/SKILL.md +185 -0
- package/.cursor/skills/deco-core-architecture/blocks.md +196 -0
- package/.cursor/skills/deco-core-architecture/deco-vs-deco-start.md +191 -0
- package/.cursor/skills/deco-core-architecture/engine.md +220 -0
- package/.cursor/skills/deco-core-architecture/hooks-components.md +157 -0
- package/.cursor/skills/deco-core-architecture/plugins-clients.md +136 -0
- package/.cursor/skills/deco-core-architecture/runtime.md +116 -0
- package/.cursor/skills/deco-core-architecture/site-usage.md +165 -0
- package/.cursor/skills/deco-e2e-testing/SKILL.md +372 -0
- package/.cursor/skills/deco-e2e-testing/discovery.md +337 -0
- package/.cursor/skills/deco-e2e-testing/scripts/scaffold.sh +81 -0
- package/.cursor/skills/deco-e2e-testing/selectors.md +175 -0
- package/.cursor/skills/deco-e2e-testing/templates/package.json +18 -0
- package/.cursor/skills/deco-e2e-testing/templates/playwright.config.ts +65 -0
- package/.cursor/skills/deco-e2e-testing/templates/scripts/baseline.ts +279 -0
- package/.cursor/skills/deco-e2e-testing/templates/scripts/run-e2e.ts +194 -0
- package/.cursor/skills/deco-e2e-testing/templates/specs/ecommerce-flow.spec.ts +612 -0
- package/.cursor/skills/deco-e2e-testing/templates/tsconfig.json +12 -0
- package/.cursor/skills/deco-e2e-testing/templates/utils/metrics-collector.ts +918 -0
- package/.cursor/skills/deco-e2e-testing/troubleshooting.md +602 -0
- package/.cursor/skills/deco-edge-caching/SKILL.md +316 -0
- package/.cursor/skills/deco-full-analysis/SKILL.md +898 -0
- package/.cursor/skills/deco-full-analysis/checklists/asset-optimization.md +251 -0
- package/.cursor/skills/deco-full-analysis/checklists/bug-fix.md +189 -0
- package/.cursor/skills/deco-full-analysis/checklists/cache-strategy.md +144 -0
- package/.cursor/skills/deco-full-analysis/checklists/dependency-update.md +150 -0
- package/.cursor/skills/deco-full-analysis/checklists/hydration-fix.md +191 -0
- package/.cursor/skills/deco-full-analysis/checklists/image-optimization.md +180 -0
- package/.cursor/skills/deco-full-analysis/checklists/loader-optimization.md +165 -0
- package/.cursor/skills/deco-full-analysis/checklists/seo-fix.md +183 -0
- package/.cursor/skills/deco-full-analysis/checklists/site-cleanup.md +281 -0
- package/.cursor/skills/deco-full-analysis/discovery.md +548 -0
- package/.cursor/skills/deco-incident-debugging/SKILL.md +378 -0
- package/.cursor/skills/deco-incident-debugging/headless-mode.md +510 -0
- package/.cursor/skills/deco-incident-debugging/learnings-index.md +227 -0
- package/.cursor/skills/deco-incident-debugging/triage-workflow.md +312 -0
- package/.cursor/skills/deco-islands-migration/SKILL.md +251 -0
- package/.cursor/skills/deco-loader-n-plus-1-detector/SKILL.md +275 -0
- package/.cursor/skills/deco-performance-audit/SKILL.md +530 -0
- package/.cursor/skills/deco-performance-audit/tools-reference.md +428 -0
- package/.cursor/skills/deco-performance-audit/workflow.md +457 -0
- package/.cursor/skills/deco-server-functions-invoke/SKILL.md +92 -0
- package/.cursor/skills/deco-server-functions-invoke/architecture.md +166 -0
- package/.cursor/skills/deco-server-functions-invoke/generator.md +122 -0
- package/.cursor/skills/deco-server-functions-invoke/problem.md +98 -0
- package/.cursor/skills/deco-server-functions-invoke/troubleshooting.md +110 -0
- package/.cursor/skills/deco-site-deployment/SKILL.md +396 -0
- package/.cursor/skills/deco-site-memory-debugging/SKILL.md +121 -0
- package/.cursor/skills/deco-site-memory-debugging/cdp-connection.md +222 -0
- package/.cursor/skills/deco-site-memory-debugging/memory-analysis.md +362 -0
- package/.cursor/skills/deco-site-patterns/SKILL.md +124 -0
- package/.cursor/skills/deco-site-patterns/app-composition.md +337 -0
- package/.cursor/skills/deco-site-patterns/client-patterns.md +341 -0
- package/.cursor/skills/deco-site-patterns/cms-wiring.md +230 -0
- package/.cursor/skills/deco-site-patterns/section-patterns.md +340 -0
- package/.cursor/skills/deco-site-scaling-tuning/SKILL.md +240 -0
- package/.cursor/skills/deco-site-scaling-tuning/analysis-scripts.md +267 -0
- package/.cursor/skills/deco-start-architecture/SKILL.md +218 -0
- package/.cursor/skills/deco-start-architecture/admin-protocol.md +156 -0
- package/.cursor/skills/deco-start-architecture/cms-resolution.md +201 -0
- package/.cursor/skills/deco-start-architecture/code-quality.md +158 -0
- package/.cursor/skills/deco-start-architecture/gap-analysis.md +129 -0
- package/.cursor/skills/deco-start-architecture/sdk-utilities.md +197 -0
- package/.cursor/skills/deco-start-architecture/worker-entry-caching.md +154 -0
- package/.cursor/skills/deco-startup-analysis/SKILL.md +248 -0
- package/.cursor/skills/deco-storefront-test-checklist/SKILL.md +369 -0
- package/.cursor/skills/deco-tanstack-hydration-fixes/SKILL.md +468 -0
- package/.cursor/skills/deco-tanstack-navigation/SKILL.md +681 -0
- package/.cursor/skills/deco-tanstack-search/SKILL.md +411 -0
- package/.cursor/skills/deco-tanstack-storefront-patterns/SKILL.md +1013 -0
- package/.cursor/skills/deco-to-tanstack-migration/SKILL.md +518 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/codemod-commands.md +174 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/commerce/README.md +78 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/deco-framework/README.md +128 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/gotchas.md +719 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/imports/README.md +70 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/platform-hooks/README.md +154 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/signals/README.md +220 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/vite-config/README.md +78 -0
- package/.cursor/skills/deco-to-tanstack-migration/templates/package-json.md +55 -0
- package/.cursor/skills/deco-to-tanstack-migration/templates/root-route.md +110 -0
- package/.cursor/skills/deco-to-tanstack-migration/templates/router.md +96 -0
- package/.cursor/skills/deco-to-tanstack-migration/templates/setup-ts.md +167 -0
- package/.cursor/skills/deco-to-tanstack-migration/templates/vite-config.md +122 -0
- package/.cursor/skills/deco-to-tanstack-migration/templates/worker-entry.md +67 -0
- package/.cursor/skills/deco-typescript-fixes/SKILL.md +178 -0
- package/.cursor/skills/deco-typescript-fixes/common-fixes.md +330 -0
- package/.cursor/skills/deco-typescript-fixes/strategy.md +148 -0
- package/.cursor/skills/deco-variant-selection-perf/SKILL.md +272 -0
- package/.cursor/skills/deco-vtex-fetch-cache/SKILL.md +225 -0
- package/.cursor/skills/find-skills/SKILL.md +133 -0
- package/.cursor/skills/incident-report/SKILL.md +179 -0
- package/.cursor/skills/incident-report/references/5-whys.md +75 -0
- package/.cursor/skills/incident-report/templates/client-report.md +187 -0
- package/.cursor/skills/incident-report/templates/internal-report.md +206 -0
- package/.cursor/skills/template-skill/SKILL.md +38 -0
- package/.github/workflows/release.yml +32 -0
- package/.releaserc.json +25 -0
- package/CLAUDE.md +135 -0
- package/GAP_ANALYSIS.md +224 -0
- package/GAP_ANALYSIS_V2.md +1013 -0
- package/biome.json +39 -0
- package/knip.json +5 -0
- package/package.json +87 -0
- package/scripts/generate-blocks.ts +69 -0
- package/scripts/generate-invoke.ts +378 -0
- package/scripts/generate-schema.ts +657 -0
- package/src/admin/cors.ts +29 -0
- package/src/admin/decofile.ts +72 -0
- package/src/admin/index.ts +24 -0
- package/src/admin/invoke.ts +163 -0
- package/src/admin/liveControls.ts +29 -0
- package/src/admin/meta.ts +70 -0
- package/src/admin/render.ts +205 -0
- package/src/admin/schema.ts +686 -0
- package/src/admin/setup.ts +44 -0
- package/src/cms/index.ts +59 -0
- package/src/cms/loader.ts +180 -0
- package/src/cms/registry.ts +162 -0
- package/src/cms/resolve.ts +1005 -0
- package/src/cms/sectionLoaders.ts +294 -0
- package/src/hooks/DecoPageRenderer.tsx +444 -0
- package/src/hooks/LazySection.tsx +109 -0
- package/src/hooks/LiveControls.tsx +108 -0
- package/src/hooks/SectionErrorFallback.tsx +85 -0
- package/src/hooks/index.ts +8 -0
- package/src/index.ts +5 -0
- package/src/matchers/builtins.ts +184 -0
- package/src/matchers/posthog.ts +154 -0
- package/src/middleware/decoState.ts +55 -0
- package/src/middleware/healthMetrics.ts +131 -0
- package/src/middleware/index.ts +80 -0
- package/src/middleware/liveness.ts +21 -0
- package/src/middleware/observability.ts +205 -0
- package/src/routes/adminRoutes.ts +83 -0
- package/src/routes/cmsRoute.ts +302 -0
- package/src/routes/components.tsx +34 -0
- package/src/routes/index.ts +15 -0
- package/src/sdk/analytics.ts +72 -0
- package/src/sdk/cacheHeaders.ts +268 -0
- package/src/sdk/cachedLoader.ts +206 -0
- package/src/sdk/clx.ts +3 -0
- package/src/sdk/cookie.ts +39 -0
- package/src/sdk/createInvoke.ts +57 -0
- package/src/sdk/csp.ts +59 -0
- package/src/sdk/env.ts +27 -0
- package/src/sdk/index.ts +63 -0
- package/src/sdk/instrumentedFetch.ts +137 -0
- package/src/sdk/invoke.ts +133 -0
- package/src/sdk/mergeCacheControl.ts +150 -0
- package/src/sdk/redirects.ts +217 -0
- package/src/sdk/requestContext.ts +184 -0
- package/src/sdk/serverTimings.ts +68 -0
- package/src/sdk/signal.ts +41 -0
- package/src/sdk/sitemap.ts +143 -0
- package/src/sdk/urlUtils.ts +117 -0
- package/src/sdk/useDevice.ts +82 -0
- package/src/sdk/useId.ts +7 -0
- package/src/sdk/useScript.ts +101 -0
- package/src/sdk/workerEntry.ts +703 -0
- package/src/sdk/wrapCaughtErrors.ts +107 -0
- package/src/types/index.ts +39 -0
- package/src/types/widgets.ts +13 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Component, type ErrorInfo, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
interface ErrorBoundaryProps {
|
|
4
|
+
sectionKey: string;
|
|
5
|
+
fallback?: ReactNode;
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ErrorBoundaryState {
|
|
10
|
+
error: Error | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Per-section error boundary that prevents a single broken section
|
|
15
|
+
* from crashing the entire page.
|
|
16
|
+
*
|
|
17
|
+
* In development, shows the error message + stack trace.
|
|
18
|
+
* In production, renders a silent empty placeholder.
|
|
19
|
+
*/
|
|
20
|
+
export class SectionErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
21
|
+
constructor(props: ErrorBoundaryProps) {
|
|
22
|
+
super(props);
|
|
23
|
+
this.state = { error: null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
27
|
+
return { error };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
31
|
+
console.error(`[CMS] Section "${this.props.sectionKey}" crashed:`, error, info.componentStack);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
render() {
|
|
35
|
+
if (this.state.error) {
|
|
36
|
+
if (this.props.fallback) return this.props.fallback;
|
|
37
|
+
return (
|
|
38
|
+
<DefaultSectionErrorFallback error={this.state.error} sectionKey={this.props.sectionKey} />
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return this.props.children;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function DefaultSectionErrorFallback({ error, sectionKey }: { error: Error; sectionKey: string }) {
|
|
46
|
+
const isDev = typeof process !== "undefined" && process.env.NODE_ENV === "development";
|
|
47
|
+
|
|
48
|
+
if (!isDev) {
|
|
49
|
+
return <div data-section-error={sectionKey} className="hidden" />;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
data-section-error={sectionKey}
|
|
55
|
+
style={{
|
|
56
|
+
padding: "16px",
|
|
57
|
+
margin: "8px 0",
|
|
58
|
+
border: "2px solid #ef4444",
|
|
59
|
+
borderRadius: "8px",
|
|
60
|
+
background: "#fef2f2",
|
|
61
|
+
fontFamily: "monospace",
|
|
62
|
+
fontSize: "13px",
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<div style={{ fontWeight: "bold", color: "#dc2626", marginBottom: "8px" }}>
|
|
66
|
+
Section Error: {sectionKey}
|
|
67
|
+
</div>
|
|
68
|
+
<div style={{ color: "#991b1b" }}>{error.message}</div>
|
|
69
|
+
{error.stack && (
|
|
70
|
+
<pre
|
|
71
|
+
style={{
|
|
72
|
+
marginTop: "8px",
|
|
73
|
+
fontSize: "11px",
|
|
74
|
+
color: "#6b7280",
|
|
75
|
+
whiteSpace: "pre-wrap",
|
|
76
|
+
overflow: "auto",
|
|
77
|
+
maxHeight: "200px",
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
{error.stack}
|
|
81
|
+
</pre>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DecoPageRenderer,
|
|
3
|
+
SectionList,
|
|
4
|
+
SectionRenderer,
|
|
5
|
+
} from "./DecoPageRenderer";
|
|
6
|
+
export { isBelowFold, LazySection, type LazySectionProps } from "./LazySection";
|
|
7
|
+
export { LiveControls } from "./LiveControls";
|
|
8
|
+
export { SectionErrorBoundary } from "./SectionErrorFallback";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in matchers matching deco-cx/apps website/matchers/*.
|
|
3
|
+
*
|
|
4
|
+
* These augment the matchers already handled inline in resolve.ts
|
|
5
|
+
* (always, never, device, random, utm) with the additional matchers
|
|
6
|
+
* that deco supported: cookie, cron, host, pathname, queryString.
|
|
7
|
+
*
|
|
8
|
+
* Register these at startup:
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { registerBuiltinMatchers } from "@decocms/start/matchers/builtins";
|
|
13
|
+
* registerBuiltinMatchers();
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { MatcherContext } from "../cms/resolve";
|
|
18
|
+
import { registerMatcher } from "../cms/resolve";
|
|
19
|
+
|
|
20
|
+
// -------------------------------------------------------------------------
|
|
21
|
+
// Cookie matcher
|
|
22
|
+
// -------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
function cookieMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
|
|
25
|
+
const name = rule.name as string | undefined;
|
|
26
|
+
const value = rule.value as string | undefined;
|
|
27
|
+
if (!name) return false;
|
|
28
|
+
|
|
29
|
+
const cookies = ctx.cookies ?? {};
|
|
30
|
+
const cookieValue = cookies[name];
|
|
31
|
+
|
|
32
|
+
if (cookieValue === undefined) return false;
|
|
33
|
+
if (value === undefined) return true;
|
|
34
|
+
return cookieValue === value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// -------------------------------------------------------------------------
|
|
38
|
+
// Cron matcher
|
|
39
|
+
// -------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
function cronMatcher(rule: Record<string, unknown>, _ctx: MatcherContext): boolean {
|
|
42
|
+
const start = rule.start as string | undefined;
|
|
43
|
+
const end = rule.end as string | undefined;
|
|
44
|
+
|
|
45
|
+
if (!start && !end) return true;
|
|
46
|
+
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
|
|
49
|
+
if (start) {
|
|
50
|
+
const startTime = new Date(start).getTime();
|
|
51
|
+
if (isNaN(startTime) || now < startTime) return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (end) {
|
|
55
|
+
const endTime = new Date(end).getTime();
|
|
56
|
+
if (isNaN(endTime) || now > endTime) return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// -------------------------------------------------------------------------
|
|
63
|
+
// Host matcher
|
|
64
|
+
// -------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
function hostMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
|
|
67
|
+
const hostToMatch = rule.host as string | undefined;
|
|
68
|
+
if (!hostToMatch) return false;
|
|
69
|
+
|
|
70
|
+
const currentUrl = ctx.url;
|
|
71
|
+
if (!currentUrl) return false;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const url = new URL(currentUrl);
|
|
75
|
+
return url.hostname === hostToMatch || url.host === hostToMatch;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// -------------------------------------------------------------------------
|
|
82
|
+
// Pathname matcher
|
|
83
|
+
// -------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
const MAX_PATTERN_LENGTH = 500;
|
|
86
|
+
const REDOS_HEURISTIC = /(\+|\*|\{[^}]*,\})\s*(\+|\*|\{[^}]*,\})/;
|
|
87
|
+
|
|
88
|
+
function isSafePattern(pattern: string): boolean {
|
|
89
|
+
if (pattern.length > MAX_PATTERN_LENGTH) return false;
|
|
90
|
+
if (REDOS_HEURISTIC.test(pattern)) return false;
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function pathnameMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
|
|
95
|
+
const pattern = rule.pattern as string | undefined;
|
|
96
|
+
const includes = rule.includes as string[] | undefined;
|
|
97
|
+
const excludes = rule.excludes as string[] | undefined;
|
|
98
|
+
|
|
99
|
+
const path = ctx.path ?? "";
|
|
100
|
+
|
|
101
|
+
if (pattern) {
|
|
102
|
+
if (!isSafePattern(pattern)) {
|
|
103
|
+
console.warn(
|
|
104
|
+
`[pathnameMatcher] Rejected potentially unsafe pattern: ${pattern.slice(0, 80)}`,
|
|
105
|
+
);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const regex = new RegExp(pattern);
|
|
110
|
+
if (!regex.test(path)) return false;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (includes && includes.length > 0) {
|
|
117
|
+
const matches = includes.some((inc) => {
|
|
118
|
+
if (inc.includes("*")) {
|
|
119
|
+
const prefix = inc.replace(/\*+$/, "");
|
|
120
|
+
return path.startsWith(prefix);
|
|
121
|
+
}
|
|
122
|
+
return path === inc;
|
|
123
|
+
});
|
|
124
|
+
if (!matches) return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (excludes && excludes.length > 0) {
|
|
128
|
+
const excluded = excludes.some((exc) => {
|
|
129
|
+
if (exc.includes("*")) {
|
|
130
|
+
const prefix = exc.replace(/\*+$/, "");
|
|
131
|
+
return path.startsWith(prefix);
|
|
132
|
+
}
|
|
133
|
+
return path === exc;
|
|
134
|
+
});
|
|
135
|
+
if (excluded) return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// -------------------------------------------------------------------------
|
|
142
|
+
// Query string matcher
|
|
143
|
+
// -------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
function queryStringMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
|
|
146
|
+
const key = (rule.key ?? rule.param) as string | undefined;
|
|
147
|
+
const value = rule.value as string | undefined;
|
|
148
|
+
|
|
149
|
+
if (!key) return false;
|
|
150
|
+
|
|
151
|
+
const currentUrl = ctx.url;
|
|
152
|
+
if (!currentUrl) return false;
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const url = new URL(currentUrl);
|
|
156
|
+
const paramValue = url.searchParams.get(key);
|
|
157
|
+
|
|
158
|
+
if (paramValue === null) return false;
|
|
159
|
+
if (value === undefined) return true;
|
|
160
|
+
return paramValue === value;
|
|
161
|
+
} catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// -------------------------------------------------------------------------
|
|
167
|
+
// Registration
|
|
168
|
+
// -------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Register all built-in matchers with the CMS resolver.
|
|
172
|
+
*
|
|
173
|
+
* Call once during app setup (in setup.ts or similar).
|
|
174
|
+
* These cover the matchers from deco-cx/apps that weren't
|
|
175
|
+
* handled inline in resolve.ts.
|
|
176
|
+
*/
|
|
177
|
+
export function registerBuiltinMatchers(): void {
|
|
178
|
+
registerMatcher("website/matchers/cookie.ts", cookieMatcher);
|
|
179
|
+
registerMatcher("website/matchers/cron.ts", cronMatcher);
|
|
180
|
+
registerMatcher("website/matchers/date.ts", cronMatcher);
|
|
181
|
+
registerMatcher("website/matchers/host.ts", hostMatcher);
|
|
182
|
+
registerMatcher("website/matchers/pathname.ts", pathnameMatcher);
|
|
183
|
+
registerMatcher("website/matchers/queryString.ts", queryStringMatcher);
|
|
184
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostHog feature flag matcher bridge.
|
|
3
|
+
*
|
|
4
|
+
* Bridges deco's CMS matcher interface (`evaluateMatcher`) to PostHog's
|
|
5
|
+
* feature flag system for experiment-grade A/B testing with sticky sessions.
|
|
6
|
+
*
|
|
7
|
+
* This module is designed to be used with `registerMatcher()` from the CMS.
|
|
8
|
+
* PostHog itself is NOT a dependency of `@decocms/start` — the storefront
|
|
9
|
+
* provides the PostHog client via `configurePostHogMatcher()`.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* // In your storefront setup.ts
|
|
14
|
+
* import { registerMatcher } from "@decocms/start/cms";
|
|
15
|
+
* import { createPostHogMatcher, configurePostHogMatcher } from "@decocms/start/matchers/posthog";
|
|
16
|
+
* import posthog from "posthog-js";
|
|
17
|
+
*
|
|
18
|
+
* configurePostHogMatcher({
|
|
19
|
+
* isFeatureEnabled: (key) => posthog.isFeatureEnabled(key) ?? false,
|
|
20
|
+
* getFeatureFlagVariant: (key) => posthog.getFeatureFlag(key),
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* registerMatcher("posthog/matchers/featureFlag.ts", createPostHogMatcher());
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { MatcherContext } from "../cms/resolve";
|
|
28
|
+
|
|
29
|
+
export interface PostHogAdapter {
|
|
30
|
+
/**
|
|
31
|
+
* Evaluate a boolean feature flag.
|
|
32
|
+
* Should return `true` if the flag is enabled for the current user.
|
|
33
|
+
*/
|
|
34
|
+
isFeatureEnabled: (flagKey: string) => boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Get the variant key for a multivariate feature flag.
|
|
37
|
+
* Returns the variant string or `undefined`/`false` if not matched.
|
|
38
|
+
*/
|
|
39
|
+
getFeatureFlagVariant?: (flagKey: string) => string | boolean | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let adapter: PostHogAdapter | null = null;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Provide the PostHog client adapter. Must be called before
|
|
46
|
+
* any PostHog matchers are evaluated.
|
|
47
|
+
*/
|
|
48
|
+
export function configurePostHogMatcher(ph: PostHogAdapter) {
|
|
49
|
+
adapter = ph;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Creates a matcher function compatible with `registerMatcher()`.
|
|
54
|
+
*
|
|
55
|
+
* CMS rule shape (in the decofile JSON):
|
|
56
|
+
* ```json
|
|
57
|
+
* {
|
|
58
|
+
* "__resolveType": "posthog/matchers/featureFlag.ts",
|
|
59
|
+
* "flagKey": "my-experiment",
|
|
60
|
+
* "variant": "treatment"
|
|
61
|
+
* }
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* - If only `flagKey` is provided, matches when the flag is enabled (boolean).
|
|
65
|
+
* - If `variant` is also provided, matches when the flag's variant equals it.
|
|
66
|
+
*/
|
|
67
|
+
export function createPostHogMatcher() {
|
|
68
|
+
return (rule: Record<string, unknown>, _ctx: MatcherContext): boolean => {
|
|
69
|
+
if (!adapter) {
|
|
70
|
+
console.warn(
|
|
71
|
+
"[PostHog Matcher] No adapter configured. Call configurePostHogMatcher() first.",
|
|
72
|
+
);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const flagKey = rule.flagKey as string | undefined;
|
|
77
|
+
if (!flagKey) {
|
|
78
|
+
console.warn("[PostHog Matcher] Missing `flagKey` in matcher rule.");
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const expectedVariant = rule.variant as string | undefined;
|
|
83
|
+
|
|
84
|
+
if (expectedVariant && adapter.getFeatureFlagVariant) {
|
|
85
|
+
const actual = adapter.getFeatureFlagVariant(flagKey);
|
|
86
|
+
return actual === expectedVariant;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return adapter.isFeatureEnabled(flagKey);
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Server-side PostHog evaluation for SSR.
|
|
95
|
+
*
|
|
96
|
+
* Uses the PostHog Node SDK to evaluate flags server-side,
|
|
97
|
+
* ensuring the first render has the correct variant without a flash.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```ts
|
|
101
|
+
* import { PostHog } from "posthog-node";
|
|
102
|
+
* import { configurePostHogMatcher, createServerPostHogAdapter } from "@decocms/start/matchers/posthog";
|
|
103
|
+
*
|
|
104
|
+
* const phServer = new PostHog(process.env.POSTHOG_API_KEY);
|
|
105
|
+
*
|
|
106
|
+
* // In your middleware, per-request:
|
|
107
|
+
* const adapter = createServerPostHogAdapter(phServer, distinctId);
|
|
108
|
+
* configurePostHogMatcher(adapter);
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export function createServerPostHogAdapter(
|
|
112
|
+
client: {
|
|
113
|
+
isFeatureEnabled: (key: string, distinctId: string) => Promise<boolean> | boolean;
|
|
114
|
+
getFeatureFlag: (
|
|
115
|
+
key: string,
|
|
116
|
+
distinctId: string,
|
|
117
|
+
) => Promise<string | boolean | undefined> | string | boolean | undefined;
|
|
118
|
+
},
|
|
119
|
+
distinctId: string,
|
|
120
|
+
): PostHogAdapter {
|
|
121
|
+
const flagCache = new Map<string, boolean>();
|
|
122
|
+
const variantCache = new Map<string, string | boolean | undefined>();
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
isFeatureEnabled(key: string): boolean {
|
|
126
|
+
if (flagCache.has(key)) return flagCache.get(key)!;
|
|
127
|
+
|
|
128
|
+
// Synchronous path — pre-warm the cache in middleware before resolution
|
|
129
|
+
const result = client.isFeatureEnabled(key, distinctId);
|
|
130
|
+
if (result instanceof Promise) {
|
|
131
|
+
console.warn(
|
|
132
|
+
"[PostHog] Async flag evaluation used synchronously. Pre-warm flags in middleware.",
|
|
133
|
+
);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
flagCache.set(key, result);
|
|
137
|
+
return result;
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
getFeatureFlagVariant(key: string): string | boolean | undefined {
|
|
141
|
+
if (variantCache.has(key)) return variantCache.get(key);
|
|
142
|
+
|
|
143
|
+
const result = client.getFeatureFlag(key, distinctId);
|
|
144
|
+
if (result instanceof Promise) {
|
|
145
|
+
console.warn(
|
|
146
|
+
"[PostHog] Async variant evaluation used synchronously. Pre-warm flags in middleware.",
|
|
147
|
+
);
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
variantCache.set(key, result);
|
|
151
|
+
return result;
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request Deco state that the middleware pipeline builds up
|
|
3
|
+
* and passes through the request lifecycle.
|
|
4
|
+
*/
|
|
5
|
+
import { createServerTimings, type ServerTimings } from "../sdk/serverTimings";
|
|
6
|
+
import type { Flag } from "../types/index";
|
|
7
|
+
|
|
8
|
+
export interface DecoState {
|
|
9
|
+
/** Site name / identifier. */
|
|
10
|
+
site: string;
|
|
11
|
+
/** Per-request Server-Timing tracker. */
|
|
12
|
+
timings: ServerTimings;
|
|
13
|
+
/** Active feature flags for this request. */
|
|
14
|
+
flags: Flag[];
|
|
15
|
+
/** Whether the request is from a deco admin origin. */
|
|
16
|
+
isAdmin: boolean;
|
|
17
|
+
/** Whether debug mode is enabled (via `?__d=true` or admin). */
|
|
18
|
+
debug: boolean;
|
|
19
|
+
/** Start time of the request (ms). */
|
|
20
|
+
startedAt: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildDecoState(request: Request, site?: string): DecoState {
|
|
24
|
+
const url = new URL(request.url);
|
|
25
|
+
const origin = request.headers.get("origin") || request.headers.get("referer") || "";
|
|
26
|
+
|
|
27
|
+
const ADMIN_ORIGINS = [
|
|
28
|
+
"https://admin.deco.cx",
|
|
29
|
+
"https://v0-admin.deco.cx",
|
|
30
|
+
"https://play.deco.cx",
|
|
31
|
+
"https://admin-cx.deco.page",
|
|
32
|
+
"https://deco.chat",
|
|
33
|
+
"https://admin.decocms.com",
|
|
34
|
+
"https://decocms.com",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const isAdmin =
|
|
38
|
+
origin.includes("localhost") ||
|
|
39
|
+
origin.includes("127.0.0.1") ||
|
|
40
|
+
ADMIN_ORIGINS.some((d) => origin.startsWith(d));
|
|
41
|
+
|
|
42
|
+
const debug = url.searchParams.has("__d") || isAdmin;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
site:
|
|
46
|
+
site ||
|
|
47
|
+
(typeof globalThis.process !== "undefined" ? globalThis.process.env?.DECO_SITE : undefined) ||
|
|
48
|
+
"storefront",
|
|
49
|
+
timings: createServerTimings(),
|
|
50
|
+
flags: [],
|
|
51
|
+
isAdmin,
|
|
52
|
+
debug,
|
|
53
|
+
startedAt: performance.now(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health metrics collection for request tracking and diagnostics.
|
|
3
|
+
*
|
|
4
|
+
* Provides atomic counters for requests (total, inflight, errors)
|
|
5
|
+
* and a health endpoint with cache stats and uptime.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { trackRequest, getHealthMetrics, handleHealthCheck } from "@decocms/start/middleware/healthMetrics";
|
|
10
|
+
*
|
|
11
|
+
* // In middleware:
|
|
12
|
+
* trackRequest.start();
|
|
13
|
+
* const response = await next();
|
|
14
|
+
* trackRequest.end(response.status);
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getLoaderCacheStats } from "../sdk/cachedLoader";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Counters
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
let totalRequests = 0;
|
|
25
|
+
let inflightRequests = 0;
|
|
26
|
+
let totalErrors = 0;
|
|
27
|
+
const startedAt = Date.now();
|
|
28
|
+
|
|
29
|
+
const statusCounts: Record<string, number> = {};
|
|
30
|
+
|
|
31
|
+
export const trackRequest = {
|
|
32
|
+
start() {
|
|
33
|
+
totalRequests++;
|
|
34
|
+
inflightRequests++;
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
end(status: number) {
|
|
38
|
+
inflightRequests--;
|
|
39
|
+
const bucket = `${Math.floor(status / 100)}xx`;
|
|
40
|
+
statusCounts[bucket] = (statusCounts[bucket] || 0) + 1;
|
|
41
|
+
if (status >= 500) totalErrors++;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Metrics access
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export interface HealthMetrics {
|
|
50
|
+
uptime: number;
|
|
51
|
+
uptimeHuman: string;
|
|
52
|
+
requests: {
|
|
53
|
+
total: number;
|
|
54
|
+
inflight: number;
|
|
55
|
+
errors: number;
|
|
56
|
+
statusCodes: Record<string, number>;
|
|
57
|
+
};
|
|
58
|
+
cache: {
|
|
59
|
+
entries: number;
|
|
60
|
+
inflight: number;
|
|
61
|
+
};
|
|
62
|
+
memory?: {
|
|
63
|
+
rss: number;
|
|
64
|
+
heapUsed: number;
|
|
65
|
+
heapTotal: number;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatUptime(ms: number): string {
|
|
70
|
+
const seconds = Math.floor(ms / 1000);
|
|
71
|
+
const h = Math.floor(seconds / 3600);
|
|
72
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
73
|
+
const s = seconds % 60;
|
|
74
|
+
return `${h}h ${m}m ${s}s`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function getHealthMetrics(): HealthMetrics {
|
|
78
|
+
const uptime = Date.now() - startedAt;
|
|
79
|
+
const cacheStats = getLoaderCacheStats();
|
|
80
|
+
|
|
81
|
+
const metrics: HealthMetrics = {
|
|
82
|
+
uptime,
|
|
83
|
+
uptimeHuman: formatUptime(uptime),
|
|
84
|
+
requests: {
|
|
85
|
+
total: totalRequests,
|
|
86
|
+
inflight: inflightRequests,
|
|
87
|
+
errors: totalErrors,
|
|
88
|
+
statusCodes: { ...statusCounts },
|
|
89
|
+
},
|
|
90
|
+
cache: cacheStats,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Memory info is only available in Node.js / Deno environments
|
|
94
|
+
if (typeof globalThis.process !== "undefined" && globalThis.process.memoryUsage) {
|
|
95
|
+
try {
|
|
96
|
+
const mem = globalThis.process.memoryUsage();
|
|
97
|
+
metrics.memory = {
|
|
98
|
+
rss: mem.rss,
|
|
99
|
+
heapUsed: mem.heapUsed,
|
|
100
|
+
heapTotal: mem.heapTotal,
|
|
101
|
+
};
|
|
102
|
+
} catch {
|
|
103
|
+
// not available in this runtime
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return metrics;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Health check handler
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Handle `/deco/_health` requests with detailed metrics.
|
|
116
|
+
* Returns null for non-matching requests.
|
|
117
|
+
*/
|
|
118
|
+
export function handleHealthCheck(request: Request): Response | null {
|
|
119
|
+
const url = new URL(request.url);
|
|
120
|
+
if (url.pathname !== "/deco/_health") return null;
|
|
121
|
+
|
|
122
|
+
const metrics = getHealthMetrics();
|
|
123
|
+
|
|
124
|
+
return new Response(JSON.stringify(metrics, null, 2), {
|
|
125
|
+
status: 200,
|
|
126
|
+
headers: {
|
|
127
|
+
"Content-Type": "application/json",
|
|
128
|
+
"Cache-Control": "no-store",
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deco middleware utilities for TanStack Start.
|
|
3
|
+
*
|
|
4
|
+
* These are NOT TanStack `createMiddleware()` instances because
|
|
5
|
+
* `@decocms/start` doesn't depend on `@tanstack/react-start`.
|
|
6
|
+
* Instead, they export composable handler functions that storefronts
|
|
7
|
+
* wire into their own `createMiddleware()` chain.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* // In your storefront's middleware.ts
|
|
12
|
+
* import { createMiddleware } from "@tanstack/react-start";
|
|
13
|
+
* import { buildDecoState, handleLiveness, applyServerTiming, applyCorsHeaders } from "@decocms/start/middleware";
|
|
14
|
+
*
|
|
15
|
+
* export const decoMiddleware = createMiddleware().server(async ({ request, next }) => {
|
|
16
|
+
* const liveness = handleLiveness(request);
|
|
17
|
+
* if (liveness) return { result: undefined, response: liveness };
|
|
18
|
+
*
|
|
19
|
+
* const state = buildDecoState(request, "my-site");
|
|
20
|
+
* const endTotal = state.timings.start("total");
|
|
21
|
+
*
|
|
22
|
+
* const result = await next();
|
|
23
|
+
*
|
|
24
|
+
* endTotal();
|
|
25
|
+
* applyServerTiming(result.response, state);
|
|
26
|
+
* if (state.isAdmin) applyCorsHeaders(result.response, request);
|
|
27
|
+
*
|
|
28
|
+
* return result;
|
|
29
|
+
* });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export { buildDecoState, type DecoState } from "./decoState";
|
|
34
|
+
export {
|
|
35
|
+
getHealthMetrics,
|
|
36
|
+
type HealthMetrics,
|
|
37
|
+
handleHealthCheck,
|
|
38
|
+
trackRequest,
|
|
39
|
+
} from "./healthMetrics";
|
|
40
|
+
export { handleLiveness } from "./liveness";
|
|
41
|
+
export {
|
|
42
|
+
configureMeter,
|
|
43
|
+
configureTracer,
|
|
44
|
+
getActiveSpan,
|
|
45
|
+
getMeter,
|
|
46
|
+
getTracer,
|
|
47
|
+
logRequest,
|
|
48
|
+
type MeterAdapter,
|
|
49
|
+
MetricNames,
|
|
50
|
+
recordCacheMetric,
|
|
51
|
+
recordRequestMetric,
|
|
52
|
+
type Span,
|
|
53
|
+
setSpanAttribute,
|
|
54
|
+
type TracerAdapter,
|
|
55
|
+
withTracing,
|
|
56
|
+
} from "./observability";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Appends Server-Timing header to a response from the accumulated timings.
|
|
60
|
+
*/
|
|
61
|
+
export function applyServerTiming(response: Response, state: { timings: { toHeader(): string } }) {
|
|
62
|
+
const header = state.timings.toHeader();
|
|
63
|
+
if (header) {
|
|
64
|
+
response.headers.append("Server-Timing", header);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Applies CORS headers for deco admin origins to a response.
|
|
70
|
+
*/
|
|
71
|
+
export function applyCorsHeaders(response: Response, request: Request) {
|
|
72
|
+
const origin = request.headers.get("origin") || "*";
|
|
73
|
+
response.headers.set("Access-Control-Allow-Origin", origin);
|
|
74
|
+
response.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
75
|
+
response.headers.set(
|
|
76
|
+
"Access-Control-Allow-Headers",
|
|
77
|
+
"Content-Type, Authorization, If-None-Match",
|
|
78
|
+
);
|
|
79
|
+
response.headers.set("Access-Control-Allow-Credentials", "true");
|
|
80
|
+
}
|