@decocms/start 2.28.1 → 2.29.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 CHANGED
@@ -1,124 +1,219 @@
1
- # @decocms/start
1
+ # @decocms/start
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@decocms/start.svg)](https://www.npmjs.com/package/@decocms/start)
4
4
  [![license](https://img.shields.io/npm/l/@decocms/start.svg)](https://github.com/decocms/deco-start/blob/main/LICENSE)
5
5
 
6
- Framework layer for [Deco](https://deco.cx) storefronts built on **TanStack Start + React 19 + Cloudflare Workers**.
6
+ Framework layer for [deco.cx](https://deco.cx) storefronts on **TanStack Start + React 19 + Cloudflare Workers**.
7
7
 
8
- Provides CMS block resolution, admin protocol handlers, section rendering, schema generation, edge caching, and SDK utilities. This is **not** a storefront — it's the npm package that storefronts depend on.
8
+ `@decocms/start` is the npm package that storefronts depend on. It provides the CMS bridge, admin protocol, section registry, schema generation, edge caching, the Vite plugin, and a small SDK. It is **not** itself a storefront — it is what storefronts build on top of.
9
9
 
10
- ## Install
10
+ 📖 **[Read the full documentation →](https://docs.deco.cx/v2/en/getting-started/overview)**
11
+
12
+ ---
13
+
14
+ ## What's in the box
11
15
 
12
- ```bash
13
- npm install @decocms/start
16
+ ```
17
+ ┌─────────────────────────────────────────────────┐
18
+ │ Site repo (your storefront) │ ← Components, sections, routes
19
+ ├─────────────────────────────────────────────────┤
20
+ │ @decocms/apps (commerce integrations) │ ← VTEX, Shopify, Resend
21
+ ├─────────────────────────────────────────────────┤
22
+ │ @decocms/start (framework — this package) │ ← CMS bridge, admin, caching
23
+ └─────────────────────────────────────────────────┘
24
+ ↓ runs on ↓
25
+ TanStack Start + React 19 + Cloudflare Workers
14
26
  ```
15
27
 
16
- ## Architecture
28
+ `@decocms/start` exports cover four surfaces:
29
+
30
+ - **Worker entry** — `createDecoWorkerEntry` wraps your Cloudflare Worker with admin routes, edge cache, and asset bypass.
31
+ - **CMS bridge** — `loadCmsPage`, `resolveDecoPage`, `registerSectionLoaders`, `registerLayoutSections`.
32
+ - **Admin protocol** — `handleMeta`, `handleDecofile`, `handleRender`, `handleInvoke`.
33
+ - **SDK** — `createCachedLoader`, `createInstrumentedFetch`, `createInvoke`, `decoVitePlugin`, plus utilities (cookies, redirects, sitemap, A/B testing).
34
+
35
+ Full export reference: [docs.deco.cx/v2/en/reference/package-exports](https://docs.deco.cx/v2/en/reference/package-exports).
36
+
37
+ ---
38
+
39
+ ## Hello, World
40
+
41
+ A minimal v2 storefront has six files. Here they are.
42
+
43
+ ### `package.json`
44
+
45
+ ```jsonc
46
+ {
47
+ "name": "my-store",
48
+ "type": "module",
49
+ "scripts": {
50
+ "dev": "vite dev",
51
+ "build": "vite build",
52
+ "deploy": "wrangler deploy"
53
+ },
54
+ "dependencies": {
55
+ "@decocms/start": "^2.28.0",
56
+ "@decocms/apps": "^1.11.0",
57
+ "@tanstack/react-start": "^1.166.0",
58
+ "react": "^19.0.0",
59
+ "react-dom": "^19.0.0"
60
+ },
61
+ "devDependencies": {
62
+ "vite": "^6.0.0",
63
+ "wrangler": "^4.72.0"
64
+ }
65
+ }
66
+ ```
17
67
 
68
+ ### `vite.config.ts`
69
+
70
+ ```ts
71
+ import { defineConfig } from "vite";
72
+ import { cloudflare } from "@cloudflare/vite-plugin";
73
+ import { tanstackStart } from "@tanstack/react-start/plugin/vite";
74
+ import react from "@vitejs/plugin-react";
75
+ import decoVitePlugin from "@decocms/start/vite";
76
+
77
+ export default defineConfig({
78
+ plugins: [
79
+ cloudflare({ viteEnvironment: { name: "ssr" } }),
80
+ tanstackStart({ server: { entry: "server" } }),
81
+ react({ babel: { plugins: ["babel-plugin-react-compiler"] } }),
82
+ decoVitePlugin(),
83
+ ],
84
+ resolve: {
85
+ alias: { "~": "/src" },
86
+ deduplicate: ["react", "react-dom", "@decocms/start", "@decocms/apps"],
87
+ },
88
+ });
18
89
  ```
19
- @decocms/start ← Framework (this package)
20
- └─ @decocms/apps ← Commerce integrations (VTEX, Shopify)
21
- └─ site repo ← UI components, routes, styles
90
+
91
+ ### `wrangler.jsonc`
92
+
93
+ ```jsonc
94
+ {
95
+ "name": "my-store",
96
+ "main": "./src/worker-entry.ts",
97
+ "compatibility_date": "2026-02-14",
98
+ "compatibility_flags": [
99
+ "nodejs_compat",
100
+ "no_handle_cross_request_promise_resolution"
101
+ ],
102
+ "assets": { "directory": "./dist/client" }
103
+ }
104
+ ```
105
+
106
+ ### `src/setup.ts`
107
+
108
+ ```ts
109
+ import { createSiteSetup } from "@decocms/start/setup";
110
+ import { applySectionConventions } from "@decocms/start/cms";
111
+
112
+ import blocks from "./server/cms/blocks.gen";
113
+ import sectionsGen from "./server/cms/sections.gen";
114
+ import meta from "./server/cms/meta.gen.json";
115
+
116
+ createSiteSetup({
117
+ sections: import.meta.glob("./sections/**/*.tsx", { eager: true }),
118
+ blocks,
119
+ meta: () => meta,
120
+ productionOrigins: ["https://my-store.com"],
121
+ });
122
+
123
+ applySectionConventions(sectionsGen);
22
124
  ```
23
125
 
24
- ### Package Exports
25
-
26
- | Import | Purpose |
27
- |--------|---------|
28
- | `@decocms/start` | Barrel export |
29
- | `@decocms/start/cms` | Block loading, page resolution, section registry |
30
- | `@decocms/start/admin` | Admin protocol (meta, decofile, invoke, render, schema) |
31
- | `@decocms/start/hooks` | DecoPageRenderer, LiveControls, LazySection |
32
- | `@decocms/start/routes` | CMS route config, admin routes |
33
- | `@decocms/start/middleware` | Observability, deco state, liveness probe |
34
- | `@decocms/start/sdk/workerEntry` | Cloudflare Worker entry with edge caching |
35
- | `@decocms/start/sdk/cacheHeaders` | URL-to-profile cache detection |
36
- | `@decocms/start/sdk/cachedLoader` | In-flight dedup for loaders |
37
- | `@decocms/start/sdk/useScript` | Inline `<script>` with minification |
38
- | `@decocms/start/sdk/useDevice` | SSR-safe device detection |
39
- | `@decocms/start/sdk/analytics` | Analytics event types |
40
- | `@decocms/start/matchers/*` | Feature flag matchers (PostHog, built-ins) |
41
- | `@decocms/start/types` | Section, App, FnContext type definitions |
42
- | `@decocms/start/scripts/*` | Code generation (blocks, schema, invoke) |
43
-
44
- ### Worker Entry Request Flow
126
+ ### `src/worker-entry.ts`
127
+
128
+ ```ts
129
+ import "./setup"; // MUST be first
130
+
131
+ import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
132
+ import {
133
+ handleMeta,
134
+ handleDecofile,
135
+ handleRender,
136
+ handleInvoke,
137
+ } from "@decocms/start/admin";
138
+ import serverEntry from "./server";
45
139
 
140
+ export default createDecoWorkerEntry(serverEntry, {
141
+ admin: { handleMeta, handleDecofile, handleRender, handleInvoke },
142
+ });
46
143
  ```
47
- Request → createDecoWorkerEntry()
48
- ├─ Admin routes (/live/_meta, /.decofile, /deco/render, /deco/invoke)
49
- ├─ Cache purge check
50
- ├─ Static asset bypass (/assets/*, favicon)
51
- ├─ Cloudflare edge cache (profile-based TTLs)
52
- └─ TanStack Start server entry
144
+
145
+ ### `src/routes/$.tsx`
146
+
147
+ ```tsx
148
+ import { createFileRoute } from "@tanstack/react-router";
149
+ import { cmsRouteConfig } from "@decocms/start/routes";
150
+
151
+ export const Route = createFileRoute("/$")(
152
+ cmsRouteConfig({ siteName: "my-store" }),
153
+ );
53
154
  ```
54
155
 
55
- ### Edge Cache Profiles
156
+ That is the entire skeleton. `npm install`, `npm run dev`, point `admin.deco.cx` at it, and you have a working CMS-driven site.
56
157
 
57
- | URL Pattern | Profile | Edge TTL |
58
- |-------------|---------|----------|
59
- | `/` | static | 1 day |
60
- | `*/p` | product | 5 min |
61
- | `/s`, `?q=` | search | 60s |
62
- | `/cart`, `/checkout` | private | none |
63
- | Everything else | listing | 2 min |
158
+ For commerce integrations (VTEX, Shopify) see [`@decocms/apps`](https://www.npmjs.com/package/@decocms/apps).
64
159
 
65
- ## Migrating from Fresh/Preact/Deno
160
+ ---
66
161
 
67
- `@decocms/start` includes an Agent Skill that handles migration for you. It works with Claude Code, Cursor, Codex, and other AI coding tools. Install the skill, open your Fresh storefront, and tell the AI to migrate:
162
+ ## Migrating from Fresh / Preact / Deno
163
+
164
+ `@decocms/start` ships an Agent Skill that handles the migration for you. It works with Claude Code, Cursor, Codex, and any tool that supports skills.
68
165
 
69
166
  ```bash
70
167
  npx skills add decocms/deco-start
71
168
  ```
72
169
 
73
- Then open your project in any supported tool and say:
170
+ Then, in your editor, point at your Fresh storefront and prompt:
74
171
 
75
172
  > migrate this project to TanStack Start
76
173
 
77
- The skill handles compatibility checking, import rewrites, config generation, section registry setup, and worker entry creation. It knows what `@decocms/start` supports and will flag anything that needs manual attention.
174
+ The skill runs the migration script, walks you through `MIGRATION_REPORT.md`, fixes typecheck/build errors interactively, and shows the diff before committing.
78
175
 
79
- ### Or run the script manually
176
+ ### Or run the script directly
80
177
 
81
178
  ```bash
82
- # From your Fresh site directory (nothing to install beforehand):
179
+ # from inside the v1 storefront directory
83
180
  npx -p @decocms/start deco-migrate
84
181
  ```
85
182
 
86
- **Options:**
183
+ The script runs seven phases (analyze → scaffold → transform → cleanup → report → verify → bootstrap), produces `MIGRATION_REPORT.md` with manual TODOs, and gets you to "compiles clean, builds clean".
184
+
185
+ Full migration playbook: [docs.deco.cx/v2/en/migration/overview](https://docs.deco.cx/v2/en/migration/overview).
87
186
 
88
- | Flag | Description |
89
- |------|-------------|
90
- | `--source <dir>` | Source directory (default: current directory) |
91
- | `--dry-run` | Preview changes without writing files |
92
- | `--verbose` | Show detailed output |
93
- | `--help`, `-h` | Show help message |
187
+ ---
94
188
 
95
- The script runs 7 phases automatically:
189
+ ## Documentation
96
190
 
97
- 1. **Analyze** scan source, detect Preact/Fresh/Deco patterns
98
- 2. **Scaffold** — generate `vite.config.ts`, `wrangler.jsonc`, routes, `setup.ts`, worker entry
99
- 3. **Transform** — rewrite imports (70+ rules), JSX attrs, Fresh APIs, Deno-isms, Tailwind v3→v4
100
- 4. **Cleanup** — delete `islands/`, old routes, `deno.json`, move `static/` → `public/`
101
- 5. **Report** — generate `MIGRATION_REPORT.md` with manual review items
102
- 6. **Verify** — 18+ smoke tests (zero old imports, scaffolded files exist)
103
- 7. **Bootstrap** — `npm install`, generate CMS blocks, generate routes
191
+ The full v2 docs live at **[docs.deco.cx/v2](https://docs.deco.cx/v2/en/getting-started/overview)**:
104
192
 
105
- Your existing `src/sections/`, `src/components/`, and `.deco/blocks/` work as-is. The script gets you to "builds clean with zero old imports" manual work starts at platform hooks (`useCart`) and runtime tuning.
193
+ - [Getting started](https://docs.deco.cx/v2/en/getting-started/overview)install paths, project structure, stack overview.
194
+ - [Concepts](https://docs.deco.cx/v2/en/concepts/sections) — sections, loaders, blocks, routes, deferred rendering.
195
+ - [Framework reference](https://docs.deco.cx/v2/en/framework/overview) — every export of `@decocms/start`, page by page.
196
+ - [Migration](https://docs.deco.cx/v2/en/migration/overview) — v1 → v2 playbook + script + skill.
197
+ - [Case studies](https://docs.deco.cx/v2/en/case-studies/overview) — three production stores end-to-end.
106
198
 
107
- ### Agent Skills
199
+ ---
108
200
 
109
- Skills live in [`.agents/skills/`](.agents/skills/) and provide deep context to AI coding tools:
201
+ ## Peer dependencies
110
202
 
111
- | Skill | What it covers |
112
- |-------|---------------|
113
- | `deco-to-tanstack-migration` | Full 12-phase migration playbook with 22 reference docs and 6 templates |
114
- | `deco-migrate-script` | How the automated `scripts/migrate.ts` works, how to extend it |
203
+ ```json
204
+ {
205
+ "@tanstack/react-start": ">=1.0.0",
206
+ "@tanstack/store": ">=0.7.0",
207
+ "@tanstack/react-query": ">=5.0.0",
208
+ "react": "^19.0.0",
209
+ "react-dom": "^19.0.0",
210
+ "vite": ">=6.0.0"
211
+ }
212
+ ```
115
213
 
116
- ## Peer Dependencies
214
+ OpenTelemetry is optional but recommended: `@microlabs/otel-cf-workers >=1.0.0-rc.0`, `@opentelemetry/api >=1.9.0`.
117
215
 
118
- - `@tanstack/react-start` >= 1.0.0
119
- - `@tanstack/store` >= 0.7.0
120
- - `react` ^19.0.0
121
- - `react-dom` ^19.0.0
216
+ ---
122
217
 
123
218
  ## Development
124
219
 
@@ -128,7 +223,11 @@ npm run lint # biome check
128
223
  npm run check # typecheck + lint + unused exports
129
224
  ```
130
225
 
131
- This is a library — no dev server. Consumer sites run their own `vite dev`.
226
+ This is a library — there is no dev server here. Consumer storefronts run their own `vite dev`.
227
+
228
+ Contributing? See `CLAUDE.md` for the architectural decisions, and `MIGRATION_TOOLING_PLAN.md` for the append-only history of the migration tooling.
229
+
230
+ ---
132
231
 
133
232
  ## License
134
233
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "2.28.1",
3
+ "version": "2.29.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -35,6 +35,11 @@
35
35
  "./sdk/invoke": "./src/sdk/invoke.ts",
36
36
  "./sdk/instrumentedFetch": "./src/sdk/instrumentedFetch.ts",
37
37
  "./sdk/otel": "./src/sdk/otel.ts",
38
+ "./sdk/logger": "./src/sdk/logger.ts",
39
+ "./sdk/composite": "./src/sdk/composite.ts",
40
+ "./sdk/otelAdapters": "./src/sdk/otelAdapters.ts",
41
+ "./sdk/sampler": "./src/sdk/sampler.ts",
42
+ "./sdk/observability": "./src/sdk/observability.ts",
38
43
  "./sdk/workerEntry": "./src/sdk/workerEntry.ts",
39
44
  "./sdk/abTesting": "./src/sdk/abTesting.ts",
40
45
  "./sdk/redirects": "./src/sdk/redirects.ts",
@@ -99,6 +104,15 @@
99
104
  },
100
105
  "dependencies": {
101
106
  "@deco-cx/warp-node": "^0.3.16",
107
+ "@microlabs/otel-cf-workers": "^1.0.0-rc.52",
108
+ "@opentelemetry/api": "^1.9.1",
109
+ "@opentelemetry/api-logs": "^0.200.0",
110
+ "@opentelemetry/exporter-logs-otlp-http": "^0.200.0",
111
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.200.0",
112
+ "@opentelemetry/resources": "^2.6.1",
113
+ "@opentelemetry/sdk-logs": "^0.200.0",
114
+ "@opentelemetry/sdk-metrics": "^2.0.0",
115
+ "@opentelemetry/sdk-trace-base": "^2.6.1",
102
116
  "clsx": "^2.1.1",
103
117
  "fast-json-patch": "^3.1.0",
104
118
  "tailwind-merge": "^3.3.1",
@@ -106,8 +120,6 @@
106
120
  "ws": "^8.18.0"
107
121
  },
108
122
  "peerDependencies": {
109
- "@microlabs/otel-cf-workers": ">=1.0.0-rc.0",
110
- "@opentelemetry/api": ">=1.9.0",
111
123
  "@tanstack/react-query": ">=5.0.0",
112
124
  "@tanstack/react-start": ">=1.0.0",
113
125
  "@tanstack/store": ">=0.7.0",
@@ -115,25 +127,15 @@
115
127
  "react-dom": "^19.0.0",
116
128
  "vite": ">=6.0.0 || >=7.0.0 || >=8.0.0"
117
129
  },
118
- "peerDependenciesMeta": {
119
- "@microlabs/otel-cf-workers": {
120
- "optional": true
121
- },
122
- "@opentelemetry/api": {
123
- "optional": true
124
- }
125
- },
126
130
  "devDependencies": {
127
131
  "@biomejs/biome": "^2.4.6",
128
- "@microlabs/otel-cf-workers": "^1.0.0-rc.52",
129
- "@opentelemetry/api": "^1.9.1",
130
132
  "@semantic-release/exec": "^7.1.0",
131
133
  "@semantic-release/git": "^10.0.1",
132
134
  "@tanstack/react-query": "^5.96.0",
133
135
  "@tanstack/store": "^0.9.1",
134
136
  "@types/react": "^19.0.0",
135
- "@types/ws": "^8.18.0",
136
137
  "@types/react-dom": "^19.0.0",
138
+ "@types/ws": "^8.18.0",
137
139
  "jsdom": "^29.0.0",
138
140
  "knip": "^5.86.0",
139
141
  "ts-morph": "^27.0.0",
@@ -1,9 +1,15 @@
1
+ import {
2
+ type ActionConfig,
3
+ type LoaderConfig,
4
+ registerActionSchemas,
5
+ registerLoaderSchemas,
6
+ } from "../admin/schema";
7
+ import { getMeter, MetricNames, withTracing } from "../middleware/observability";
8
+ import { djb2Hex } from "../sdk/djb2";
9
+ import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
1
10
  import { findPageByPath, loadBlocks } from "./loader";
2
11
  import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } from "./registry";
3
12
  import { isLayoutSection, runSingleSectionLoader } from "./sectionLoaders";
4
- import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
5
- import { djb2Hex } from "../sdk/djb2";
6
- import { registerLoaderSchemas, registerActionSchemas, type LoaderConfig, type ActionConfig } from "../admin/schema";
7
13
 
8
14
  // globalThis-backed: share state across Vite server function split modules
9
15
  const G = globalThis as any;
@@ -134,10 +140,7 @@ export function setAsyncRenderingConfig(config?: {
134
140
  respectCmsLazy?: boolean;
135
141
  }): void {
136
142
  const existing = getAsyncConfig();
137
- const merged = new Set([
138
- ...(existing?.alwaysEager ?? []),
139
- ...(config?.alwaysEager ?? []),
140
- ]);
143
+ const merged = new Set([...(existing?.alwaysEager ?? []), ...(config?.alwaysEager ?? [])]);
141
144
  G.__deco.asyncConfig = {
142
145
  respectCmsLazy: config?.respectCmsLazy ?? existing?.respectCmsLazy ?? true,
143
146
  foldThreshold: config?.foldThreshold ?? existing?.foldThreshold ?? Infinity,
@@ -321,7 +324,13 @@ export function registerCommerceLoaders(loaders: Record<string, CommerceLoader>)
321
324
  if (key.includes("/actions/")) {
322
325
  actionConfigs.push({ key, title: key, namespace, propsSchema: schema });
323
326
  } else {
324
- loaderConfigs.push({ key, title: key, namespace, propsSchema: schema, tags: inferLoaderTags(key) });
327
+ loaderConfigs.push({
328
+ key,
329
+ title: key,
330
+ namespace,
331
+ propsSchema: schema,
332
+ tags: inferLoaderTags(key),
333
+ });
325
334
  }
326
335
  }
327
336
 
@@ -374,18 +383,24 @@ export function registerMatcher(
374
383
  if (!G.__deco._builtinMatchersRegistered) {
375
384
  G.__deco._builtinMatchersRegistered = true;
376
385
 
377
- const builtinMatchers: Record<string, (rule: Record<string, unknown>, ctx: MatcherContext) => boolean> = {
386
+ const builtinMatchers: Record<
387
+ string,
388
+ (rule: Record<string, unknown>, ctx: MatcherContext) => boolean
389
+ > = {
378
390
  "website/matchers/always.ts": () => true,
379
391
  "$live/matchers/MatchAlways.ts": () => true,
380
392
  "website/matchers/never.ts": () => false,
381
393
  "website/matchers/device.ts": (rule, ctx) => {
382
394
  const ua = (ctx.userAgent || "").toLowerCase();
383
395
  const isTablet = /ipad|android(?!.*mobile)|tablet/i.test(ua);
384
- const isMobile = !isTablet && /mobile|android|iphone|ipod|webos|blackberry|opera mini|iemobile/i.test(ua);
396
+ const isMobile =
397
+ !isTablet && /mobile|android|iphone|ipod|webos|blackberry|opera mini|iemobile/i.test(ua);
385
398
  const isDesktop = !isMobile && !isTablet;
386
399
  // If no flags are set, match everything (permissive default)
387
400
  if (!rule.mobile && !rule.tablet && !rule.desktop) return true;
388
- return !!(rule.mobile && isMobile) || !!(rule.tablet && isTablet) || !!(rule.desktop && isDesktop);
401
+ return (
402
+ !!(rule.mobile && isMobile) || !!(rule.tablet && isTablet) || !!(rule.desktop && isDesktop)
403
+ );
389
404
  },
390
405
  "website/matchers/random.ts": (rule) => {
391
406
  const traffic = typeof rule.traffic === "number" ? rule.traffic : 0.5;
@@ -457,7 +472,10 @@ function ensureInitialized() {
457
472
  // Matcher evaluation
458
473
  // ---------------------------------------------------------------------------
459
474
 
460
- export function evaluateMatcher(rule: Record<string, unknown> | undefined, ctx: MatcherContext): boolean {
475
+ export function evaluateMatcher(
476
+ rule: Record<string, unknown> | undefined,
477
+ ctx: MatcherContext,
478
+ ): boolean {
461
479
  if (!rule) return true;
462
480
 
463
481
  const resolveType = rule.__resolveType as string | undefined;
@@ -828,10 +846,7 @@ export async function resolvePageSeoBlock(
828
846
  }
829
847
 
830
848
  // Multivariate flag — evaluate matcher and follow matched variant
831
- if (
832
- rt === "website/flags/multivariate.ts" ||
833
- rt === "website/flags/multivariate/section.ts"
834
- ) {
849
+ if (rt === "website/flags/multivariate.ts" || rt === "website/flags/multivariate/section.ts") {
835
850
  const variants = current.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
836
851
  if (!variants?.length) return null;
837
852
  let matched: unknown = null;
@@ -909,10 +924,7 @@ function isRawSectionLayout(section: unknown): string | null {
909
924
  * unwrapping Lazy/Deferred wrappers, and evaluating multivariate flags.
910
925
  * Returns null if not determinable.
911
926
  */
912
- function resolveFinalSectionKey(
913
- section: unknown,
914
- matcherCtx?: MatcherContext,
915
- ): string | null {
927
+ function resolveFinalSectionKey(section: unknown, matcherCtx?: MatcherContext): string | null {
916
928
  if (!section || typeof section !== "object") return null;
917
929
 
918
930
  const blocks = loadBlocks();
@@ -946,13 +958,8 @@ function resolveFinalSectionKey(
946
958
  continue;
947
959
  }
948
960
 
949
- if (
950
- rt === WELL_KNOWN_TYPES.MULTIVARIATE ||
951
- rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION
952
- ) {
953
- const variants = current.variants as
954
- | Array<{ value: unknown; rule?: unknown }>
955
- | undefined;
961
+ if (rt === WELL_KNOWN_TYPES.MULTIVARIATE || rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION) {
962
+ const variants = current.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
956
963
  if (!variants?.length) return null;
957
964
 
958
965
  let matched: unknown = null;
@@ -999,21 +1006,13 @@ function isCmsDeferralWrapped(section: unknown, matcherCtx?: MatcherContext): bo
999
1006
  const rt = current.__resolveType as string | undefined;
1000
1007
  if (!rt) return false;
1001
1008
 
1002
- if (
1003
- rt === WELL_KNOWN_TYPES.LAZY ||
1004
- rt === WELL_KNOWN_TYPES.DEFERRED
1005
- ) {
1009
+ if (rt === WELL_KNOWN_TYPES.LAZY || rt === WELL_KNOWN_TYPES.DEFERRED) {
1006
1010
  return true;
1007
1011
  }
1008
1012
 
1009
1013
  // Walk through multivariate flags to check the matched variant
1010
- if (
1011
- rt === WELL_KNOWN_TYPES.MULTIVARIATE ||
1012
- rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION
1013
- ) {
1014
- const variants = current.variants as
1015
- | Array<{ value: unknown; rule?: unknown }>
1016
- | undefined;
1014
+ if (rt === WELL_KNOWN_TYPES.MULTIVARIATE || rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION) {
1015
+ const variants = current.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
1017
1016
  if (!variants?.length) return false;
1018
1017
 
1019
1018
  let matched: unknown = null;
@@ -1125,13 +1124,8 @@ function resolveSectionShallow(
1125
1124
  }
1126
1125
 
1127
1126
  // Multivariate flags — evaluate matchers and continue with matched variant
1128
- if (
1129
- rt === WELL_KNOWN_TYPES.MULTIVARIATE ||
1130
- rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION
1131
- ) {
1132
- const variants = current.variants as
1133
- | Array<{ value: unknown; rule?: unknown }>
1134
- | undefined;
1127
+ if (rt === WELL_KNOWN_TYPES.MULTIVARIATE || rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION) {
1128
+ const variants = current.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
1135
1129
  if (!variants?.length) return null;
1136
1130
 
1137
1131
  let matched: unknown = null;
@@ -1331,6 +1325,30 @@ export interface DecoPageResult {
1331
1325
  export async function resolveDecoPage(
1332
1326
  targetPath: string,
1333
1327
  matcherCtx?: MatcherContext,
1328
+ ): Promise<DecoPageResult | null> {
1329
+ const startedAt = performance.now();
1330
+ return withTracing(
1331
+ "deco.cms.resolvePage",
1332
+ async () => {
1333
+ const result = await resolveDecoPageImpl(targetPath, matcherCtx);
1334
+ try {
1335
+ getMeter()?.histogramRecord?.(
1336
+ MetricNames.RESOLVE_DURATION_MS,
1337
+ performance.now() - startedAt,
1338
+ { path: targetPath },
1339
+ );
1340
+ } catch {
1341
+ /* observability never fails the request */
1342
+ }
1343
+ return result;
1344
+ },
1345
+ { "deco.route": targetPath },
1346
+ );
1347
+ }
1348
+
1349
+ async function resolveDecoPageImpl(
1350
+ targetPath: string,
1351
+ matcherCtx?: MatcherContext,
1334
1352
  ): Promise<DecoPageResult | null> {
1335
1353
  ensureInitialized();
1336
1354
 
@@ -1378,7 +1396,12 @@ export async function resolveDecoPage(
1378
1396
  // Cache rawProps server-side and strip from the deferred object
1379
1397
  // so they are NOT serialized into the HTML payload.
1380
1398
  if (deferred.rawProps) {
1381
- cacheDeferredRawProps(targetPath, deferred.component, currentFlatIndex, deferred.rawProps);
1399
+ cacheDeferredRawProps(
1400
+ targetPath,
1401
+ deferred.component,
1402
+ currentFlatIndex,
1403
+ deferred.rawProps,
1404
+ );
1382
1405
  delete deferred.rawProps;
1383
1406
  }
1384
1407
 
@@ -1430,10 +1453,12 @@ export async function resolveDecoPage(
1430
1453
  })();
1431
1454
 
1432
1455
  const idx = currentFlatIndex;
1433
- eagerResults.push(promise.then((sections) => {
1434
- for (const s of sections) s.index = idx;
1435
- return sections;
1436
- }));
1456
+ eagerResults.push(
1457
+ promise.then((sections) => {
1458
+ for (const s of sections) s.index = idx;
1459
+ return sections;
1460
+ }),
1461
+ );
1437
1462
  flatIndex++;
1438
1463
  }
1439
1464
  }
@@ -1445,10 +1470,7 @@ export async function resolveDecoPage(
1445
1470
  let seoSection: ResolvedSection | null = null;
1446
1471
  if (page.seo) {
1447
1472
  try {
1448
- seoSection = await resolvePageSeoBlock(
1449
- page.seo as Record<string, unknown>,
1450
- rctx,
1451
- );
1473
+ seoSection = await resolvePageSeoBlock(page.seo as Record<string, unknown>, rctx);
1452
1474
  } catch (e) {
1453
1475
  onResolveError(e, "page.seo", "Page SEO block resolution");
1454
1476
  }
@@ -1588,18 +1610,14 @@ export async function resolveDeferredSectionFull(
1588
1610
  matcherCtx?: MatcherContext,
1589
1611
  ): Promise<ResolvedSection | null> {
1590
1612
  // rawProps may be stripped from the client payload — resolve from cache or page
1591
- const rawProps = ds.rawProps
1592
- ?? getDeferredRawProps(pagePath, ds.component, ds.index)
1593
- ?? await reExtractRawProps(pagePath, ds.component, ds.index, matcherCtx);
1613
+ const rawProps =
1614
+ ds.rawProps ??
1615
+ getDeferredRawProps(pagePath, ds.component, ds.index) ??
1616
+ (await reExtractRawProps(pagePath, ds.component, ds.index, matcherCtx));
1594
1617
 
1595
1618
  if (!rawProps) return null;
1596
1619
 
1597
- const section = await resolveDeferredSection(
1598
- ds.component,
1599
- rawProps,
1600
- pagePath,
1601
- matcherCtx,
1602
- );
1620
+ const section = await resolveDeferredSection(ds.component, rawProps, pagePath, matcherCtx);
1603
1621
  if (!section) return null;
1604
1622
  section.index = ds.index;
1605
1623
  const enriched = await runSingleSectionLoader(section, request);
@@ -8,6 +8,8 @@
8
8
  * This runs AFTER resolveDecoPage and BEFORE React rendering,
9
9
  * inside the TanStack Start server function.
10
10
  */
11
+
12
+ import { withTracing } from "../middleware/observability";
11
13
  import { getCacheProfile } from "../sdk/cacheHeaders";
12
14
  import { djb2 } from "../sdk/djb2";
13
15
  import type { ResolvedSection } from "./resolve";
@@ -326,6 +328,15 @@ function withPageContext(loader: SectionLoaderFn): SectionLoaderFn {
326
328
  export async function runSingleSectionLoader(
327
329
  section: ResolvedSection,
328
330
  request: Request,
331
+ ): Promise<ResolvedSection> {
332
+ return withTracing("deco.section.loader", () => runSingleSectionLoaderImpl(section, request), {
333
+ "deco.section": section.component,
334
+ });
335
+ }
336
+
337
+ async function runSingleSectionLoaderImpl(
338
+ section: ResolvedSection,
339
+ request: Request,
329
340
  ): Promise<ResolvedSection> {
330
341
  const loader = loaderRegistry.get(section.component);
331
342