@decocms/start 2.28.2 → 2.30.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/.agents/skills/deco-to-tanstack-migration/SKILL.md +1 -1
- package/.agents/skills/deco-to-tanstack-migration/references/vtex-commerce.md +5 -1
- package/.agents/skills/deco-to-tanstack-migration/references/worker-cloudflare.md +107 -10
- package/.agents/skills/deco-to-tanstack-migration/templates/package-json.md +5 -1
- package/.cursor/rules/migration-tooling-policy.mdc +22 -2
- package/.github/workflows/deploy.yml +115 -0
- package/.github/workflows/preview.yml +143 -0
- package/.github/workflows/regen-blocks.yml +56 -0
- package/.github/workflows/release.yml +26 -0
- package/.github/workflows/sync-secrets.yml +173 -0
- package/CODEOWNERS +16 -0
- package/MIGRATION_TOOLING_PLAN.md +16 -4
- package/README.md +178 -79
- package/deploy/README.md +85 -0
- package/deploy/sites/als-tanstack.jsonc +7 -0
- package/deploy/sites/americanas-tanstack.jsonc +4 -0
- package/deploy/sites/baggagio-tanstack.jsonc +4 -0
- package/deploy/sites/casaevideo-storefront.jsonc +11 -0
- package/deploy/sites/lebiscuit-tanstack.jsonc +19 -0
- package/deploy/sites/miess-01-tanstack.jsonc +8 -0
- package/deploy/wrangler-template.jsonc +28 -0
- package/package.json +18 -15
- package/scripts/deploy/build-wrangler-config.mjs +49 -0
- package/scripts/deploy/jsonc.mjs +76 -0
- package/scripts/deploy/resolve-site.mjs +58 -0
- package/scripts/deploy/site-registry.mjs +142 -0
- package/scripts/deploy/wrangler-wrapper.mjs +126 -0
- package/scripts/migrate/phase-scaffold.ts +13 -3
- package/scripts/migrate/phase-verify.ts +6 -1
- package/scripts/migrate/templates/github-workflows.ts +98 -0
- package/scripts/migrate/templates/package-json.ts +9 -2
- package/src/cms/resolve.ts +81 -63
- package/src/cms/sectionLoaders.ts +11 -0
- package/src/index.ts +3 -0
- package/src/sdk/cachedLoader.ts +36 -13
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/instrumentedFetch.ts +56 -0
- package/src/sdk/logger.test.ts +135 -0
- package/src/sdk/logger.ts +166 -0
- package/src/sdk/observability.ts +75 -0
- package/src/sdk/otel.test.ts +59 -0
- package/src/sdk/otel.ts +270 -29
- package/src/sdk/otelAdapters.test.ts +135 -0
- package/src/sdk/otelAdapters.ts +401 -0
- package/src/sdk/sampler.test.ts +127 -0
- package/src/sdk/sampler.ts +183 -0
- package/src/sdk/workerEntry.ts +541 -476
- package/scripts/migrate/templates/wrangler.ts +0 -30
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Caller workflow stubs for new sites. Each stub delegates to a reusable
|
|
2
|
+
// workflow under `decocms/deco-start/.github/workflows/` -- the customer repo
|
|
3
|
+
// holds no deploy/build logic of its own. See D6 in
|
|
4
|
+
// `.cursor/rules/migration-tooling-policy.mdc` and the `deploy/` directory
|
|
5
|
+
// for the central registry contract.
|
|
6
|
+
|
|
7
|
+
const DEPLOY_YML = `name: Deploy
|
|
8
|
+
|
|
9
|
+
# Thin caller for decocms/deco-start's central deploy workflow.
|
|
10
|
+
|
|
11
|
+
on:
|
|
12
|
+
push:
|
|
13
|
+
branches: [main]
|
|
14
|
+
|
|
15
|
+
permissions:
|
|
16
|
+
contents: write
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
deploy:
|
|
20
|
+
uses: decocms/deco-start/.github/workflows/deploy.yml@v2
|
|
21
|
+
secrets: inherit
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
const PREVIEW_YML = `name: Preview
|
|
25
|
+
|
|
26
|
+
# Thin caller for decocms/deco-start's central preview workflow.
|
|
27
|
+
|
|
28
|
+
on:
|
|
29
|
+
repository_dispatch:
|
|
30
|
+
types: [preview-deploy]
|
|
31
|
+
pull_request:
|
|
32
|
+
types: [opened, synchronize, reopened]
|
|
33
|
+
push:
|
|
34
|
+
branches: ['env/**']
|
|
35
|
+
|
|
36
|
+
permissions:
|
|
37
|
+
contents: read
|
|
38
|
+
pull-requests: write
|
|
39
|
+
statuses: write
|
|
40
|
+
|
|
41
|
+
jobs:
|
|
42
|
+
preview:
|
|
43
|
+
uses: decocms/deco-start/.github/workflows/preview.yml@v2
|
|
44
|
+
secrets: inherit
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
const REGEN_BLOCKS_YML = `name: Regenerate blocks.gen.json
|
|
48
|
+
|
|
49
|
+
# Thin caller for decocms/deco-start's central regen-blocks workflow.
|
|
50
|
+
|
|
51
|
+
on:
|
|
52
|
+
push:
|
|
53
|
+
branches: [main]
|
|
54
|
+
paths:
|
|
55
|
+
- ".deco/blocks/**"
|
|
56
|
+
|
|
57
|
+
permissions:
|
|
58
|
+
contents: write
|
|
59
|
+
|
|
60
|
+
jobs:
|
|
61
|
+
regen:
|
|
62
|
+
uses: decocms/deco-start/.github/workflows/regen-blocks.yml@v2
|
|
63
|
+
secrets: inherit
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
const SYNC_SECRETS_YML = `name: Sync worker secrets
|
|
67
|
+
|
|
68
|
+
# Thin caller for decocms/deco-start's central sync-secrets workflow.
|
|
69
|
+
|
|
70
|
+
on:
|
|
71
|
+
workflow_dispatch:
|
|
72
|
+
inputs:
|
|
73
|
+
mode:
|
|
74
|
+
description: "dry-run = print diff only | apply = set secrets on worker"
|
|
75
|
+
required: true
|
|
76
|
+
default: "dry-run"
|
|
77
|
+
type: choice
|
|
78
|
+
options: [dry-run, apply]
|
|
79
|
+
|
|
80
|
+
permissions:
|
|
81
|
+
contents: read
|
|
82
|
+
|
|
83
|
+
jobs:
|
|
84
|
+
sync:
|
|
85
|
+
uses: decocms/deco-start/.github/workflows/sync-secrets.yml@v2
|
|
86
|
+
with:
|
|
87
|
+
mode: \${{ inputs.mode }}
|
|
88
|
+
secrets: inherit
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
export function generateGithubWorkflows(): Record<string, string> {
|
|
92
|
+
return {
|
|
93
|
+
".github/workflows/deploy.yml": DEPLOY_YML,
|
|
94
|
+
".github/workflows/preview.yml": PREVIEW_YML,
|
|
95
|
+
".github/workflows/regen-blocks.yml": REGEN_BLOCKS_YML,
|
|
96
|
+
".github/workflows/sync-secrets.yml": SYNC_SECRETS_YML,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -109,8 +109,15 @@ export function generatePackageJson(ctx: MigrationContext): string {
|
|
|
109
109
|
build:
|
|
110
110
|
"npm run generate:blocks && npm run generate:sections && npm run generate:loaders && npm run generate:schema && npm run generate:invoke && tsr generate && vite build",
|
|
111
111
|
preview: "vite preview",
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
// wrangler.jsonc is generated by `deco-wrangler gen` from the central
|
|
113
|
+
// registry in @decocms/start (D6). Predev/prebuild ensure it is up to
|
|
114
|
+
// date before vite runs the @cloudflare/vite-plugin which reads it.
|
|
115
|
+
"gen:wrangler": "deco-wrangler gen",
|
|
116
|
+
predev: "deco-wrangler gen",
|
|
117
|
+
prebuild: "deco-wrangler gen",
|
|
118
|
+
deploy:
|
|
119
|
+
"echo 'Production deploys are managed by .github/workflows/deploy.yml on push to main. For an emergency manual deploy run: npx deco-wrangler deploy'; exit 1",
|
|
120
|
+
types: "deco-wrangler types",
|
|
114
121
|
typecheck: "tsc --noEmit",
|
|
115
122
|
format: 'prettier --write "src/**/*.{ts,tsx}"',
|
|
116
123
|
"format:check": 'prettier --check "src/**/*.{ts,tsx}"',
|
package/src/cms/resolve.ts
CHANGED
|
@@ -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({
|
|
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<
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
1434
|
-
|
|
1435
|
-
|
|
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 =
|
|
1592
|
-
|
|
1593
|
-
|
|
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
|
|
package/src/index.ts
CHANGED
|
@@ -3,4 +3,7 @@ export * from "./admin/index";
|
|
|
3
3
|
export * from "./cms/index";
|
|
4
4
|
export * from "./hooks/index";
|
|
5
5
|
export * from "./middleware/index";
|
|
6
|
+
// Observability surface — logger + instrumentWorker live behind their own
|
|
7
|
+
// granular imports too (see `@decocms/start/sdk/logger`, `.../observability`).
|
|
8
|
+
export { type Logger, type LogLevel, logger, setLogLevel } from "./sdk/logger";
|
|
6
9
|
export * from "./types/index";
|
package/src/sdk/cachedLoader.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* (e.g. "product") which derives timing from the unified profile system.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { recordCacheMetric, withTracing } from "../middleware/observability";
|
|
15
|
+
import { type CacheProfileName, loaderCacheOptions } from "./cacheHeaders";
|
|
15
16
|
|
|
16
17
|
export type CachePolicy = "no-store" | "no-cache" | "stale-while-revalidate";
|
|
17
18
|
|
|
@@ -85,12 +86,7 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
85
86
|
optionsOrProfile: CachedLoaderOptions | CacheProfileName,
|
|
86
87
|
): (props: TProps) => Promise<TResult> {
|
|
87
88
|
const resolved = resolveOptions(optionsOrProfile);
|
|
88
|
-
const {
|
|
89
|
-
policy,
|
|
90
|
-
maxAge = DEFAULT_MAX_AGE,
|
|
91
|
-
staleIfError = 0,
|
|
92
|
-
keyFn = JSON.stringify,
|
|
93
|
-
} = resolved;
|
|
89
|
+
const { policy, maxAge = DEFAULT_MAX_AGE, staleIfError = 0, keyFn = JSON.stringify } = resolved;
|
|
94
90
|
|
|
95
91
|
const env = typeof globalThis.process !== "undefined" ? globalThis.process.env : undefined;
|
|
96
92
|
const isDev = env?.DECO_CACHE_DISABLE === "true" || env?.NODE_ENV === "development";
|
|
@@ -101,10 +97,20 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
101
97
|
const cacheKey = `${name}::${keyFn(props)}`;
|
|
102
98
|
|
|
103
99
|
const inflight = inflightRequests.get(cacheKey);
|
|
104
|
-
if (inflight)
|
|
100
|
+
if (inflight) {
|
|
101
|
+
// Treat in-flight dedup as a cache hit — avoided the origin call.
|
|
102
|
+
recordCacheMetric(true, name);
|
|
103
|
+
return inflight as Promise<TResult>;
|
|
104
|
+
}
|
|
105
105
|
|
|
106
106
|
if (isDev) {
|
|
107
|
-
|
|
107
|
+
// Dev mode: no caching, but still useful to count attempts.
|
|
108
|
+
recordCacheMetric(false, name);
|
|
109
|
+
const promise = withTracing(
|
|
110
|
+
"deco.cachedLoader",
|
|
111
|
+
() => loaderFn(props).finally(() => inflightRequests.delete(cacheKey)),
|
|
112
|
+
{ "deco.loader": name, "deco.cache.policy": "no-cache-dev" },
|
|
113
|
+
);
|
|
108
114
|
inflightRequests.set(cacheKey, promise);
|
|
109
115
|
return promise;
|
|
110
116
|
}
|
|
@@ -114,13 +120,21 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
114
120
|
const isStale = entry ? now - entry.createdAt > maxAge : true;
|
|
115
121
|
|
|
116
122
|
if (policy === "no-cache") {
|
|
117
|
-
if (entry && !isStale)
|
|
123
|
+
if (entry && !isStale) {
|
|
124
|
+
recordCacheMetric(true, name);
|
|
125
|
+
return entry.value;
|
|
126
|
+
}
|
|
118
127
|
}
|
|
119
128
|
|
|
120
129
|
if (policy === "stale-while-revalidate") {
|
|
121
|
-
if (entry && !isStale)
|
|
130
|
+
if (entry && !isStale) {
|
|
131
|
+
recordCacheMetric(true, name);
|
|
132
|
+
return entry.value;
|
|
133
|
+
}
|
|
122
134
|
|
|
123
135
|
if (entry && isStale && !entry.refreshing) {
|
|
136
|
+
// Stale-while-revalidate hit: serve stale, refresh in background.
|
|
137
|
+
recordCacheMetric(true, name);
|
|
124
138
|
entry.refreshing = true;
|
|
125
139
|
loaderFn(props)
|
|
126
140
|
.then((result) => {
|
|
@@ -141,10 +155,19 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
141
155
|
return entry.value;
|
|
142
156
|
}
|
|
143
157
|
|
|
144
|
-
if (entry)
|
|
158
|
+
if (entry) {
|
|
159
|
+
recordCacheMetric(true, name);
|
|
160
|
+
return entry.value;
|
|
161
|
+
}
|
|
145
162
|
}
|
|
146
163
|
|
|
147
|
-
|
|
164
|
+
// Cache miss — emit metric, then run loader inside a span so individual
|
|
165
|
+
// slow loaders are visible in traces.
|
|
166
|
+
recordCacheMetric(false, name);
|
|
167
|
+
const promise = withTracing("deco.cachedLoader", () => loaderFn(props), {
|
|
168
|
+
"deco.loader": name,
|
|
169
|
+
"deco.cache.policy": policy,
|
|
170
|
+
})
|
|
148
171
|
.then((result) => {
|
|
149
172
|
cache.set(cacheKey, {
|
|
150
173
|
value: result,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { MeterAdapter } from "../middleware/observability";
|
|
3
|
+
import { createCompositeLogger, createCompositeMeter } from "./composite";
|
|
4
|
+
import type { LoggerAdapter } from "./logger";
|
|
5
|
+
|
|
6
|
+
describe("createCompositeLogger", () => {
|
|
7
|
+
afterEach(() => vi.restoreAllMocks());
|
|
8
|
+
|
|
9
|
+
it("fans calls out to every adapter", () => {
|
|
10
|
+
const aCalls: any[][] = [];
|
|
11
|
+
const bCalls: any[][] = [];
|
|
12
|
+
const a: LoggerAdapter = { log: (...args) => void aCalls.push(args) };
|
|
13
|
+
const b: LoggerAdapter = { log: (...args) => void bCalls.push(args) };
|
|
14
|
+
const composite = createCompositeLogger([a, b]);
|
|
15
|
+
|
|
16
|
+
composite.log("info", "hello", { foo: 1 });
|
|
17
|
+
|
|
18
|
+
expect(aCalls).toEqual([["info", "hello", { foo: 1 }]]);
|
|
19
|
+
expect(bCalls).toEqual([["info", "hello", { foo: 1 }]]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("filters falsy entries (null/undefined/false)", () => {
|
|
23
|
+
const calls: any[] = [];
|
|
24
|
+
const a: LoggerAdapter = { log: (...args) => void calls.push(args) };
|
|
25
|
+
const composite = createCompositeLogger([null, a, undefined, false]);
|
|
26
|
+
composite.log("info", "ok");
|
|
27
|
+
// adapter receives the call with no third arg (undefined is omitted)
|
|
28
|
+
expect(calls.length).toBe(1);
|
|
29
|
+
expect(calls[0][0]).toBe("info");
|
|
30
|
+
expect(calls[0][1]).toBe("ok");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("isolates errors so one bad adapter does not block others", () => {
|
|
34
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
35
|
+
const okCalls: any[][] = [];
|
|
36
|
+
const broken: LoggerAdapter = {
|
|
37
|
+
log() {
|
|
38
|
+
throw new Error("boom");
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const ok: LoggerAdapter = { log: (...args) => void okCalls.push(args) };
|
|
42
|
+
const composite = createCompositeLogger([broken, ok]);
|
|
43
|
+
|
|
44
|
+
expect(() => composite.log("error", "still goes through")).not.toThrow();
|
|
45
|
+
expect(okCalls.length).toBe(1);
|
|
46
|
+
expect(okCalls[0][0]).toBe("error");
|
|
47
|
+
expect(okCalls[0][1]).toBe("still goes through");
|
|
48
|
+
expect(errSpy).toHaveBeenCalled(); // composite reports the failure
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns the single adapter directly when only one is provided", () => {
|
|
52
|
+
const a: LoggerAdapter = { log: () => {} };
|
|
53
|
+
expect(createCompositeLogger([a])).toBe(a);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("createCompositeMeter", () => {
|
|
58
|
+
afterEach(() => vi.restoreAllMocks());
|
|
59
|
+
|
|
60
|
+
function recorder() {
|
|
61
|
+
const counter: any[] = [];
|
|
62
|
+
const gauge: any[] = [];
|
|
63
|
+
const histo: any[] = [];
|
|
64
|
+
const meter: MeterAdapter = {
|
|
65
|
+
counterInc: (n, v, l) => void counter.push([n, v, l]),
|
|
66
|
+
gaugeSet: (n, v, l) => void gauge.push([n, v, l]),
|
|
67
|
+
histogramRecord: (n, v, l) => void histo.push([n, v, l]),
|
|
68
|
+
};
|
|
69
|
+
return { meter, counter, gauge, histo };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
it("fans counter/gauge/histogram across meters", () => {
|
|
73
|
+
const a = recorder();
|
|
74
|
+
const b = recorder();
|
|
75
|
+
const composite = createCompositeMeter([a.meter, b.meter]);
|
|
76
|
+
|
|
77
|
+
composite.counterInc("c", 1, { p: "/" });
|
|
78
|
+
composite.gaugeSet?.("g", 5);
|
|
79
|
+
composite.histogramRecord?.("h", 100, { route: "/p" });
|
|
80
|
+
|
|
81
|
+
expect(a.counter[0].slice(0, 2)).toEqual(["c", 1]);
|
|
82
|
+
expect(a.counter[0][2]).toEqual({ p: "/" });
|
|
83
|
+
expect(b.counter[0].slice(0, 2)).toEqual(["c", 1]);
|
|
84
|
+
expect(a.gauge[0].slice(0, 2)).toEqual(["g", 5]);
|
|
85
|
+
expect(b.gauge[0].slice(0, 2)).toEqual(["g", 5]);
|
|
86
|
+
expect(a.histo[0]).toEqual(["h", 100, { route: "/p" }]);
|
|
87
|
+
expect(b.histo[0]).toEqual(["h", 100, { route: "/p" }]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("isolates errors per meter and per call type", () => {
|
|
91
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
92
|
+
const broken: MeterAdapter = {
|
|
93
|
+
counterInc: () => {
|
|
94
|
+
throw new Error("c");
|
|
95
|
+
},
|
|
96
|
+
gaugeSet: () => {
|
|
97
|
+
throw new Error("g");
|
|
98
|
+
},
|
|
99
|
+
histogramRecord: () => {
|
|
100
|
+
throw new Error("h");
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
const ok = recorder();
|
|
104
|
+
const composite = createCompositeMeter([broken, ok.meter]);
|
|
105
|
+
|
|
106
|
+
composite.counterInc("c", 1);
|
|
107
|
+
composite.gaugeSet?.("g", 5);
|
|
108
|
+
composite.histogramRecord?.("h", 100);
|
|
109
|
+
|
|
110
|
+
expect(ok.counter[0].slice(0, 2)).toEqual(["c", 1]);
|
|
111
|
+
expect(ok.gauge[0].slice(0, 2)).toEqual(["g", 5]);
|
|
112
|
+
expect(ok.histo[0].slice(0, 2)).toEqual(["h", 100]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("skips meters that don't implement optional ops", () => {
|
|
116
|
+
const onlyCounter: MeterAdapter = { counterInc: () => {} };
|
|
117
|
+
const composite = createCompositeMeter([onlyCounter]);
|
|
118
|
+
expect(() => composite.gaugeSet?.("g", 1)).not.toThrow();
|
|
119
|
+
expect(() => composite.histogramRecord?.("h", 1)).not.toThrow();
|
|
120
|
+
});
|
|
121
|
+
});
|