@decocms/start 6.1.0 → 6.2.1
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-migrate-script/SKILL.md +4 -3
- package/.cursor/skills/deco-apps-vtex-porting/cookie-auth-patterns.md +13 -0
- package/.cursor/skills/deco-apps-vtex-review/SKILL.md +15 -0
- package/.cursor/skills/deco-server-functions-invoke/troubleshooting.md +22 -0
- package/docs/rum-plan.md +209 -0
- package/docs/runbooks/README.md +40 -0
- package/docs/runbooks/cache-hit-drop.md +83 -0
- package/docs/runbooks/commerce-upstream-slow.md +88 -0
- package/docs/runbooks/http-error-spike.md +98 -0
- package/docs/runbooks/http-latency-spike.md +82 -0
- package/docs/runbooks/tail-exception-spike.md +100 -0
- package/package.json +1 -1
- package/scripts/audit-observability-config.test.ts +251 -1
- package/scripts/audit-observability-config.ts +227 -26
- package/scripts/migrate/post-cleanup/rules.ts +90 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +103 -0
- package/scripts/migrate.ts +13 -0
- package/src/admin/invoke.test.ts +141 -0
- package/src/admin/invoke.ts +47 -14
|
@@ -15,27 +15,45 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Rules (id — severity — what it catches):
|
|
17
17
|
*
|
|
18
|
-
* observability_missing
|
|
19
|
-
* observability_disabled
|
|
20
|
-
* traces_disabled
|
|
21
|
-
* logs_disabled
|
|
22
|
-
* head_sampling_rate_elevated
|
|
23
|
-
* logs_head_sampling_rate_low
|
|
24
|
-
* persist_disabled_no_destination
|
|
18
|
+
* observability_missing error No `observability` key at all. CF captures nothing.
|
|
19
|
+
* observability_disabled error `observability.enabled: false`. Master switch off.
|
|
20
|
+
* traces_disabled warn `observability.traces.enabled: false`. No traces in dashboard.
|
|
21
|
+
* logs_disabled warn `observability.logs.enabled: false`. No logs in dashboard.
|
|
22
|
+
* head_sampling_rate_elevated error `traces.head_sampling_rate > 0.01`. Fleet-scale cost risk; see docs/observability.md.
|
|
23
|
+
* logs_head_sampling_rate_low warn `logs.head_sampling_rate < 1`. Sampling info/warn logs loses signal cheaply; errors go via the direct-POST channel.
|
|
24
|
+
* persist_disabled_no_destination error `persist: false` with no destination configured. Data captured then discarded.
|
|
25
|
+
*
|
|
26
|
+
* Phase 6 / D-14 — fleet-config drift rules (live outside the
|
|
27
|
+
* `observability` block but still owned by this audit):
|
|
28
|
+
*
|
|
29
|
+
* version_metadata_binding_missing error Missing `version_metadata` binding. `service.version` won't be stamped — regressions can't be attributed to a deploy.
|
|
30
|
+
* analytics_engine_binding_missing warn No `DECO_METRICS` AE binding. AE meter is off; OTLP meter still works but CF dashboard panels go dark.
|
|
31
|
+
* tail_consumer_missing error No `tail_consumers` entry pointing at `deco-otel-tail`. 100% error-capture is broken.
|
|
32
|
+
* otel_metrics_endpoint_missing warn `DECO_OTEL_METRICS_ENDPOINT` not set on `vars`. OTLP meter is off; only AE works.
|
|
33
|
+
* otel_traces_endpoint_missing warn `DECO_OTEL_TRACES_ENDPOINT` not set on `vars`. Framework `deco.*` spans drop unless CF Traces is on.
|
|
34
|
+
* otel_logs_endpoint_missing warn `DECO_OTEL_LOGS_ENDPOINT` not set on `vars`. Error logs ride CF Destinations only (head-sampled).
|
|
25
35
|
*
|
|
26
36
|
* Usage (from a site directory):
|
|
27
|
-
* npx -p @decocms/start deco-audit-observability # audit cwd
|
|
28
|
-
* npx -p @decocms/start deco-audit-observability --source ./ # explicit
|
|
37
|
+
* npx -p @decocms/start deco-audit-observability # audit cwd (warn mode — exit 0)
|
|
38
|
+
* npx -p @decocms/start deco-audit-observability --source ./ # explicit source dir
|
|
29
39
|
* npx -p @decocms/start deco-audit-observability --json # machine-readable
|
|
40
|
+
* npx -p @decocms/start deco-audit-observability --mode block # error findings exit 1 (CI gate)
|
|
41
|
+
* npx -p @decocms/start deco-audit-observability --github # GitHub Actions annotations
|
|
30
42
|
*
|
|
31
43
|
* Options:
|
|
32
44
|
* --source <dir> Site directory (default: .)
|
|
33
|
-
* --json Emit findings as JSON to stdout
|
|
45
|
+
* --json Emit findings as JSON to stdout
|
|
46
|
+
* --mode <m> Gate hardness: "warn" (default — always exit 0 on findings,
|
|
47
|
+
* just print them) or "block" (exit 1 on any `error` finding).
|
|
48
|
+
* See D-16 in MIGRATION_TOOLING_PLAN.md for the rationale on
|
|
49
|
+
* why warn is the v1 default.
|
|
50
|
+
* --github Emit `::warning::` / `::error::` lines for GitHub Actions
|
|
51
|
+
* annotations in addition to the normal text output.
|
|
34
52
|
* --help, -h Show this message
|
|
35
53
|
*
|
|
36
54
|
* Exit codes:
|
|
37
|
-
* 0 — no findings
|
|
38
|
-
* 1 — at least one finding
|
|
55
|
+
* 0 — no findings, or `--mode warn` (the default) regardless of findings
|
|
56
|
+
* 1 — `--mode block` and at least one `error`-severity finding
|
|
39
57
|
* 2 — file invalid / can't parse
|
|
40
58
|
*/
|
|
41
59
|
|
|
@@ -53,14 +71,24 @@ export interface Finding {
|
|
|
53
71
|
fix?: string;
|
|
54
72
|
}
|
|
55
73
|
|
|
74
|
+
export type GateMode = "warn" | "block";
|
|
75
|
+
|
|
56
76
|
interface CliOpts {
|
|
57
77
|
source: string;
|
|
58
78
|
json: boolean;
|
|
59
79
|
help: boolean;
|
|
80
|
+
mode: GateMode;
|
|
81
|
+
github: boolean;
|
|
60
82
|
}
|
|
61
83
|
|
|
62
84
|
function parseArgs(argv: string[]): CliOpts {
|
|
63
|
-
const opts: CliOpts = {
|
|
85
|
+
const opts: CliOpts = {
|
|
86
|
+
source: ".",
|
|
87
|
+
json: false,
|
|
88
|
+
help: false,
|
|
89
|
+
mode: "warn",
|
|
90
|
+
github: false,
|
|
91
|
+
};
|
|
64
92
|
for (let i = 0; i < argv.length; i++) {
|
|
65
93
|
const flag = argv[i];
|
|
66
94
|
switch (flag) {
|
|
@@ -70,6 +98,20 @@ function parseArgs(argv: string[]): CliOpts {
|
|
|
70
98
|
case "--json":
|
|
71
99
|
opts.json = true;
|
|
72
100
|
break;
|
|
101
|
+
case "--mode": {
|
|
102
|
+
const value = argv[++i];
|
|
103
|
+
if (value !== "warn" && value !== "block") {
|
|
104
|
+
console.error(
|
|
105
|
+
`audit: --mode must be "warn" or "block" (got "${value ?? ""}")`,
|
|
106
|
+
);
|
|
107
|
+
process.exit(2);
|
|
108
|
+
}
|
|
109
|
+
opts.mode = value;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case "--github":
|
|
113
|
+
opts.github = true;
|
|
114
|
+
break;
|
|
73
115
|
case "--help":
|
|
74
116
|
case "-h":
|
|
75
117
|
opts.help = true;
|
|
@@ -91,14 +133,18 @@ function showHelp(): void {
|
|
|
91
133
|
npx -p @decocms/start deco-audit-observability [options]
|
|
92
134
|
|
|
93
135
|
Options:
|
|
94
|
-
--source <dir>
|
|
95
|
-
--json
|
|
96
|
-
--
|
|
136
|
+
--source <dir> Site directory (default: .)
|
|
137
|
+
--json Emit findings as JSON
|
|
138
|
+
--mode <m> "warn" (default, exit 0) | "block" (exit 1 on errors)
|
|
139
|
+
--github Emit ::warning::/::error:: lines for GitHub Actions
|
|
140
|
+
--help, -h This message
|
|
97
141
|
|
|
98
142
|
Exit codes:
|
|
99
|
-
0 no findings
|
|
100
|
-
1
|
|
143
|
+
0 no findings, OR --mode warn (default — annotate and move on)
|
|
144
|
+
1 --mode block AND at least one error-severity finding
|
|
101
145
|
2 wrangler.jsonc missing or unparseable
|
|
146
|
+
|
|
147
|
+
See D-16 in MIGRATION_TOOLING_PLAN.md for the v1 "warn-only" policy.
|
|
102
148
|
`);
|
|
103
149
|
}
|
|
104
150
|
|
|
@@ -236,6 +282,138 @@ export function auditObservabilityBlock(
|
|
|
236
282
|
return findings;
|
|
237
283
|
}
|
|
238
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Fleet-config drift rules — owned by the same audit because the
|
|
287
|
+
* cumulative effect of "observability block correct, bindings missing"
|
|
288
|
+
* is identical to "observability block missing" (no data lands in
|
|
289
|
+
* ClickHouse).
|
|
290
|
+
*
|
|
291
|
+
* The CLI composes `auditObservabilityBlock` + `auditFleetBindings`.
|
|
292
|
+
* Both return Finding[]; callers concatenate.
|
|
293
|
+
*/
|
|
294
|
+
export interface WranglerLike {
|
|
295
|
+
observability?: ObservabilityBlock;
|
|
296
|
+
version_metadata?: { binding?: string } | unknown;
|
|
297
|
+
analytics_engine_datasets?: Array<{ binding?: string; dataset?: string }> | unknown;
|
|
298
|
+
tail_consumers?: Array<{ service?: string; environment?: string }> | unknown;
|
|
299
|
+
vars?: Record<string, unknown> | unknown;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function auditFleetBindings(wrangler: WranglerLike): Finding[] {
|
|
303
|
+
const findings: Finding[] = [];
|
|
304
|
+
|
|
305
|
+
// version_metadata — required so `service.version` is stamped on every
|
|
306
|
+
// span and log line. Without it, regressions can't be tied to a
|
|
307
|
+
// specific deployment.
|
|
308
|
+
const vmBinding =
|
|
309
|
+
typeof wrangler.version_metadata === "object" &&
|
|
310
|
+
wrangler.version_metadata !== null &&
|
|
311
|
+
"binding" in wrangler.version_metadata
|
|
312
|
+
? (wrangler.version_metadata as { binding?: string }).binding
|
|
313
|
+
: undefined;
|
|
314
|
+
if (!vmBinding) {
|
|
315
|
+
findings.push({
|
|
316
|
+
id: "version_metadata_binding_missing",
|
|
317
|
+
severity: "error",
|
|
318
|
+
message:
|
|
319
|
+
"wrangler.jsonc is missing a `version_metadata.binding` entry. " +
|
|
320
|
+
"`service.version` won't appear on spans/logs and the deploy-correlation " +
|
|
321
|
+
"panel will be empty. Recommended value: `CF_VERSION_METADATA`.",
|
|
322
|
+
fix: "npx -p @decocms/start deco-cf-observability --write",
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// DECO_METRICS — Analytics Engine binding. The AE meter is the hot-
|
|
327
|
+
// path CF dashboard view; OTLP works without it but we lose the
|
|
328
|
+
// operator-grade short-window panels.
|
|
329
|
+
const aeDatasets = Array.isArray(wrangler.analytics_engine_datasets)
|
|
330
|
+
? (wrangler.analytics_engine_datasets as Array<{ binding?: string }>)
|
|
331
|
+
: [];
|
|
332
|
+
const hasMetricsBinding = aeDatasets.some((d) => d?.binding === "DECO_METRICS");
|
|
333
|
+
if (!hasMetricsBinding) {
|
|
334
|
+
findings.push({
|
|
335
|
+
id: "analytics_engine_binding_missing",
|
|
336
|
+
severity: "warn",
|
|
337
|
+
message:
|
|
338
|
+
"wrangler.jsonc has no `analytics_engine_datasets[].binding == 'DECO_METRICS'`. " +
|
|
339
|
+
"The AE meter is off; the hot-path operator dashboards in CF will be empty. " +
|
|
340
|
+
"OTLP metrics keep flowing if `DECO_OTEL_METRICS_ENDPOINT` is set.",
|
|
341
|
+
fix: "npx -p @decocms/start deco-cf-observability --write",
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// tail_consumers — must list deco-otel-tail. Phase 1 enrichment is
|
|
346
|
+
// useless without the tail consumer firing.
|
|
347
|
+
const tail = Array.isArray(wrangler.tail_consumers)
|
|
348
|
+
? (wrangler.tail_consumers as Array<{ service?: string }>)
|
|
349
|
+
: [];
|
|
350
|
+
const hasTailConsumer = tail.some((t) => t?.service === "deco-otel-tail");
|
|
351
|
+
if (!hasTailConsumer) {
|
|
352
|
+
findings.push({
|
|
353
|
+
id: "tail_consumer_missing",
|
|
354
|
+
severity: "error",
|
|
355
|
+
message:
|
|
356
|
+
"wrangler.jsonc has no `tail_consumers[].service == 'deco-otel-tail'` entry. " +
|
|
357
|
+
"100% error-capture is broken — only the head-sampled CF Destinations path " +
|
|
358
|
+
"will report errors, and isolate crashes will be invisible.",
|
|
359
|
+
fix: "npx -p @decocms/start deco-cf-observability --write",
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// DECO_OTEL_*_ENDPOINT env vars. Audit each separately so the message
|
|
364
|
+
// explains which channel is silently no-op.
|
|
365
|
+
const vars =
|
|
366
|
+
typeof wrangler.vars === "object" && wrangler.vars !== null
|
|
367
|
+
? (wrangler.vars as Record<string, unknown>)
|
|
368
|
+
: {};
|
|
369
|
+
const checkVar = (id: string, name: string, severity: Severity, channel: string) => {
|
|
370
|
+
const v = vars[name];
|
|
371
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
372
|
+
findings.push({
|
|
373
|
+
id,
|
|
374
|
+
severity,
|
|
375
|
+
message:
|
|
376
|
+
`wrangler.jsonc \`vars.${name}\` is not set. ${channel} is a no-op; ` +
|
|
377
|
+
`data captured in this channel never lands in ClickHouse. ` +
|
|
378
|
+
`See docs/observability.md for the canonical endpoints.`,
|
|
379
|
+
fix: "npx -p @decocms/start deco-cf-observability --write",
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
checkVar(
|
|
384
|
+
"otel_metrics_endpoint_missing",
|
|
385
|
+
"DECO_OTEL_METRICS_ENDPOINT",
|
|
386
|
+
"warn",
|
|
387
|
+
"OTLP metrics direct-POST",
|
|
388
|
+
);
|
|
389
|
+
checkVar(
|
|
390
|
+
"otel_traces_endpoint_missing",
|
|
391
|
+
"DECO_OTEL_TRACES_ENDPOINT",
|
|
392
|
+
"warn",
|
|
393
|
+
"OTLP traces direct-POST",
|
|
394
|
+
);
|
|
395
|
+
checkVar(
|
|
396
|
+
"otel_logs_endpoint_missing",
|
|
397
|
+
"DECO_OTEL_LOGS_ENDPOINT",
|
|
398
|
+
"warn",
|
|
399
|
+
"OTLP error-log direct-POST",
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
return findings;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* One-stop call for the full wrangler audit — composes the
|
|
407
|
+
* observability-block rules with the fleet-binding rules. Both keep
|
|
408
|
+
* working standalone for fine-grained tests.
|
|
409
|
+
*/
|
|
410
|
+
export function auditWranglerConfig(wrangler: WranglerLike): Finding[] {
|
|
411
|
+
return [
|
|
412
|
+
...auditObservabilityBlock(wrangler.observability),
|
|
413
|
+
...auditFleetBindings(wrangler),
|
|
414
|
+
];
|
|
415
|
+
}
|
|
416
|
+
|
|
239
417
|
function findingsToText(file: string, findings: Finding[]): string {
|
|
240
418
|
if (findings.length === 0) {
|
|
241
419
|
return `OK ${file} — observability config looks canonical`;
|
|
@@ -263,26 +441,49 @@ function main(): void {
|
|
|
263
441
|
process.exit(2);
|
|
264
442
|
}
|
|
265
443
|
|
|
266
|
-
let parsed:
|
|
444
|
+
let parsed: WranglerLike;
|
|
267
445
|
try {
|
|
268
|
-
parsed = parseJsonc(fs.readFileSync(file, "utf8"));
|
|
446
|
+
parsed = parseJsonc(fs.readFileSync(file, "utf8")) as WranglerLike;
|
|
269
447
|
} catch (err) {
|
|
270
448
|
console.error(`audit: ${file} could not be parsed: ${(err as Error).message}`);
|
|
271
449
|
process.exit(2);
|
|
272
450
|
}
|
|
273
451
|
|
|
274
|
-
const findings =
|
|
452
|
+
const findings = auditWranglerConfig(parsed);
|
|
275
453
|
|
|
276
454
|
if (opts.json) {
|
|
277
|
-
process.stdout.write(
|
|
455
|
+
process.stdout.write(
|
|
456
|
+
JSON.stringify({ file, mode: opts.mode, findings }, null, 2) + "\n",
|
|
457
|
+
);
|
|
278
458
|
} else {
|
|
279
459
|
process.stdout.write(findingsToText(file, findings) + "\n");
|
|
280
460
|
}
|
|
281
461
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
462
|
+
if (opts.github) {
|
|
463
|
+
for (const f of findings) {
|
|
464
|
+
// GitHub Actions workflow command. `error` and `warning` annotate the
|
|
465
|
+
// diff; `notice` is informational. We never emit `error` in warn mode
|
|
466
|
+
// even for error-severity rules — the v1 policy is annotate-don't-fail.
|
|
467
|
+
const level = opts.mode === "block" && f.severity === "error"
|
|
468
|
+
? "error"
|
|
469
|
+
: f.severity === "info" ? "notice" : "warning";
|
|
470
|
+
const msg = `${f.message}${f.fix ? ` (fix: ${f.fix})` : ""}`;
|
|
471
|
+
const escaped = msg.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(
|
|
472
|
+
/\n/g,
|
|
473
|
+
"%0A",
|
|
474
|
+
);
|
|
475
|
+
process.stdout.write(`::${level} title=${f.id}::${escaped}\n`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Exit policy: D-16 / Phase 6 decision.
|
|
480
|
+
// warn — annotate only; always exit 0 (CI sees the findings but ships)
|
|
481
|
+
// block — exit 1 on any `error`-severity finding
|
|
482
|
+
// The default is `warn` because storefronts are upgraded over weeks; a
|
|
483
|
+
// day-one block would fail PRs that have nothing to do with observability.
|
|
484
|
+
const shouldFail = opts.mode === "block" &&
|
|
485
|
+
findings.some((f) => f.severity === "error");
|
|
486
|
+
process.exit(shouldFail ? 1 : 0);
|
|
286
487
|
}
|
|
287
488
|
|
|
288
489
|
// Only run when invoked directly, not when imported by tests.
|
|
@@ -1578,12 +1578,101 @@ const rulePackageManagerMissing: Rule = {
|
|
|
1578
1578
|
},
|
|
1579
1579
|
};
|
|
1580
1580
|
|
|
1581
|
+
/* ------------------------------------------------------------------ */
|
|
1582
|
+
/* Rule N — `vtex-proxy-handler-missing` — worker-entry without proxy */
|
|
1583
|
+
/* ------------------------------------------------------------------ */
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* Every VTEX storefront on @decocms/start needs a reverse proxy for
|
|
1587
|
+
* `/checkout/*`, `/account/*`, `/api/*`, `/files/*`, etc. — those paths
|
|
1588
|
+
* must hit the VTEX origin (not TanStack Start) so the user lands on
|
|
1589
|
+
* the real checkout UI carrying their VTEX session cookies.
|
|
1590
|
+
*
|
|
1591
|
+
* The canonical wiring lives in `src/worker-entry.ts` via
|
|
1592
|
+
* `createDecoWorkerEntry(..., { proxyHandler })`, where the handler
|
|
1593
|
+
* calls `shouldProxyToVtex(url.pathname)` + a `createVtexCheckoutProxy`
|
|
1594
|
+
* instance. The migration scaffold (`scripts/migrate/templates/server-entry.ts`)
|
|
1595
|
+
* emits this by default for VTEX sites, but two regressions sneak it out:
|
|
1596
|
+
*
|
|
1597
|
+
* 1. A site migrated by a pre-scaffold version of the script (e.g.
|
|
1598
|
+
* before the VTEX worker-entry template existed).
|
|
1599
|
+
* 2. Someone refactors `worker-entry.ts` and drops the proxy block.
|
|
1600
|
+
*
|
|
1601
|
+
* Without the proxy, add-to-cart still "succeeds" (the action runs
|
|
1602
|
+
* server-side via TanStack RPC), but clicking "Finalizar" navigates
|
|
1603
|
+
* to `/checkout` on the storefront — which returns the SPA shell —
|
|
1604
|
+
* and the user never reaches VTEX checkout. The VTEX-side orderForm
|
|
1605
|
+
* lives at vtexcommercestable.com.br with no way to see it.
|
|
1606
|
+
*
|
|
1607
|
+
* Detection is cheap: VTEX sites should import `shouldProxyToVtex`
|
|
1608
|
+
* (or wire a `proxyHandler:` to `createDecoWorkerEntry`). We flag
|
|
1609
|
+
* absence as `info` (not warning) because old in-prod sites we
|
|
1610
|
+
* deliberately don't touch would otherwise stay noisy.
|
|
1611
|
+
*/
|
|
1612
|
+
const ruleVtexProxyHandlerMissing: Rule = {
|
|
1613
|
+
id: "vtex-proxy-handler-missing",
|
|
1614
|
+
title: "VTEX worker-entry missing the checkout/API proxy handler",
|
|
1615
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
1616
|
+
// Only run when the site is actually VTEX. The cheapest signal is
|
|
1617
|
+
// any import from `@decocms/apps/vtex/...` in src/ — every VTEX
|
|
1618
|
+
// site has at least one (commerceLoaders, hooks, types, etc.).
|
|
1619
|
+
const srcFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
|
|
1620
|
+
const isVtex = srcFiles.some((abs) =>
|
|
1621
|
+
fs.readText(abs).includes("@decocms/apps/vtex"),
|
|
1622
|
+
);
|
|
1623
|
+
if (!isVtex) return [];
|
|
1624
|
+
|
|
1625
|
+
const workerEntryAbs = `${siteDir}/src/worker-entry.ts`;
|
|
1626
|
+
if (!fs.exists(workerEntryAbs)) {
|
|
1627
|
+
return [
|
|
1628
|
+
{
|
|
1629
|
+
rule: "vtex-proxy-handler-missing",
|
|
1630
|
+
severity: "info",
|
|
1631
|
+
file: "src/worker-entry.ts",
|
|
1632
|
+
message:
|
|
1633
|
+
"VTEX site has no src/worker-entry.ts — /checkout proxy can't run, the user will see the SPA shell instead of VTEX checkout",
|
|
1634
|
+
fix: "Re-run `deco-migrate` (the scaffold emits a worker-entry with createVtexCheckoutProxy), or copy from scripts/migrate/templates/server-entry.ts",
|
|
1635
|
+
},
|
|
1636
|
+
];
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
const content = fs.readText(workerEntryAbs);
|
|
1640
|
+
// Match either symbol — sites use the factory function OR the
|
|
1641
|
+
// shouldProxyToVtex predicate as the entry point. Presence of
|
|
1642
|
+
// either is a strong signal the proxy block exists; absence of
|
|
1643
|
+
// both means it was dropped.
|
|
1644
|
+
const hasProxyImport =
|
|
1645
|
+
/from\s+["']@decocms\/apps\/vtex\/utils\/proxy["']/.test(content);
|
|
1646
|
+
// Match both long form (`proxyHandler: async (...) => ...`) and
|
|
1647
|
+
// object-shorthand wiring (`{ proxyHandler }`, `{ proxyHandler, admin }`).
|
|
1648
|
+
// The anchor `[{,]` requires the identifier to appear as a property —
|
|
1649
|
+
// not as a bare `const proxyHandler = ...` declaration, which is
|
|
1650
|
+
// followed by `=` and wouldn't match either branch.
|
|
1651
|
+
const hasProxyHandler = /[{,]\s*proxyHandler\s*[:,}]/.test(content);
|
|
1652
|
+
|
|
1653
|
+
if (hasProxyImport && hasProxyHandler) return [];
|
|
1654
|
+
|
|
1655
|
+
return [
|
|
1656
|
+
{
|
|
1657
|
+
rule: "vtex-proxy-handler-missing",
|
|
1658
|
+
severity: "info",
|
|
1659
|
+
file: "src/worker-entry.ts",
|
|
1660
|
+
message: hasProxyImport
|
|
1661
|
+
? "imports proxy helpers but no `proxyHandler:` is wired into createDecoWorkerEntry — /checkout requests will fall through to TanStack and render the SPA shell"
|
|
1662
|
+
: "no `@decocms/apps/vtex/utils/proxy` import — VTEX /checkout, /account, /api won't be proxied to the origin",
|
|
1663
|
+
fix: "Add `proxyHandler` to createDecoWorkerEntry; see scripts/migrate/templates/server-entry.ts (generateVtexWorkerEntry) for the canonical block",
|
|
1664
|
+
},
|
|
1665
|
+
];
|
|
1666
|
+
},
|
|
1667
|
+
};
|
|
1668
|
+
|
|
1581
1669
|
export const ALL_RULES: Rule[] = [
|
|
1582
1670
|
ruleDeadLibShims,
|
|
1583
1671
|
ruleObsoleteVitePlugins,
|
|
1584
1672
|
ruleDeadRuntimeShim,
|
|
1585
1673
|
ruleSiteLocalGlobals,
|
|
1586
1674
|
ruleVtexShimRegression,
|
|
1675
|
+
ruleVtexProxyHandlerMissing,
|
|
1587
1676
|
ruleLocalWidgetsTypes,
|
|
1588
1677
|
ruleFrameworkTodos,
|
|
1589
1678
|
ruleLocalFrameworkDuplicate,
|
|
@@ -1606,6 +1695,7 @@ export const _internals = {
|
|
|
1606
1695
|
ruleDeadRuntimeShim,
|
|
1607
1696
|
ruleSiteLocalGlobals,
|
|
1608
1697
|
ruleVtexShimRegression,
|
|
1698
|
+
ruleVtexProxyHandlerMissing,
|
|
1609
1699
|
ruleHtmxResidue,
|
|
1610
1700
|
ruleLocalWidgetsTypes,
|
|
1611
1701
|
ruleFrameworkTodos,
|
|
@@ -628,6 +628,109 @@ describe("rule: framework-todos", () => {
|
|
|
628
628
|
});
|
|
629
629
|
});
|
|
630
630
|
|
|
631
|
+
describe("rule: vtex-proxy-handler-missing", () => {
|
|
632
|
+
// Canonical wiring matching scripts/migrate/templates/server-entry.ts (generateVtexWorkerEntry):
|
|
633
|
+
// imports from @decocms/apps/vtex/utils/proxy and passes proxyHandler to createDecoWorkerEntry.
|
|
634
|
+
const okWorkerEntry = `
|
|
635
|
+
import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
|
|
636
|
+
import { shouldProxyToVtex, createVtexCheckoutProxy } from "@decocms/apps/vtex/utils/proxy";
|
|
637
|
+
const proxy = createVtexCheckoutProxy({ account: "x", checkoutOrigin: "x.vtexcommercestable.com.br" });
|
|
638
|
+
export default createDecoWorkerEntry(serverEntry, {
|
|
639
|
+
proxyHandler: async (req, url) => {
|
|
640
|
+
if (!shouldProxyToVtex(url.pathname)) return null;
|
|
641
|
+
return proxy(req, url);
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
`;
|
|
645
|
+
|
|
646
|
+
it("does not flag non-VTEX sites", () => {
|
|
647
|
+
const fs = makeFs({
|
|
648
|
+
"/site/src/index.ts": "export const x = 1;",
|
|
649
|
+
// Worker entry intentionally without the proxy import — non-VTEX
|
|
650
|
+
// sites don't need it and shouldn't be flagged.
|
|
651
|
+
"/site/src/worker-entry.ts": "export default {};",
|
|
652
|
+
});
|
|
653
|
+
const report = runAudit(SITE, fs);
|
|
654
|
+
const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
|
|
655
|
+
expect(r.findings).toEqual([]);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("does not flag VTEX site whose worker-entry has the proxyHandler wired", () => {
|
|
659
|
+
const fs = makeFs({
|
|
660
|
+
"/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
|
|
661
|
+
"/site/src/worker-entry.ts": okWorkerEntry,
|
|
662
|
+
});
|
|
663
|
+
const report = runAudit(SITE, fs);
|
|
664
|
+
const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
|
|
665
|
+
expect(r.findings).toEqual([]);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("flags VTEX site missing src/worker-entry.ts entirely", () => {
|
|
669
|
+
const fs = makeFs({
|
|
670
|
+
"/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
|
|
671
|
+
});
|
|
672
|
+
const report = runAudit(SITE, fs);
|
|
673
|
+
const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
|
|
674
|
+
expect(r.findings).toHaveLength(1);
|
|
675
|
+
expect(r.findings[0].severity).toBe("info");
|
|
676
|
+
expect(r.findings[0].message).toContain("no src/worker-entry.ts");
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it("flags VTEX site whose worker-entry omits the proxy import", () => {
|
|
680
|
+
const fs = makeFs({
|
|
681
|
+
"/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
|
|
682
|
+
"/site/src/worker-entry.ts": `
|
|
683
|
+
import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
|
|
684
|
+
export default createDecoWorkerEntry(serverEntry, { admin: {} });
|
|
685
|
+
`,
|
|
686
|
+
});
|
|
687
|
+
const report = runAudit(SITE, fs);
|
|
688
|
+
const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
|
|
689
|
+
expect(r.findings).toHaveLength(1);
|
|
690
|
+
expect(r.findings[0].message).toContain("no `@decocms/apps/vtex/utils/proxy` import");
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it("does not flag VTEX site using object-shorthand proxyHandler wiring", () => {
|
|
694
|
+
// Hand-written worker entries commonly hoist proxyHandler to a top-level
|
|
695
|
+
// const and pass it via shorthand. The detector must treat
|
|
696
|
+
// `{ proxyHandler }`, `{ proxyHandler, admin }`, and `{ admin, proxyHandler }`
|
|
697
|
+
// the same as `proxyHandler: ...` — otherwise the audit cries wolf.
|
|
698
|
+
const shorthandWorkerEntry = `
|
|
699
|
+
import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
|
|
700
|
+
import { shouldProxyToVtex, createVtexCheckoutProxy } from "@decocms/apps/vtex/utils/proxy";
|
|
701
|
+
const proxy = createVtexCheckoutProxy({ account: "x", checkoutOrigin: "x.vtexcommercestable.com.br" });
|
|
702
|
+
const proxyHandler = async (req: Request, url: URL) => {
|
|
703
|
+
if (!shouldProxyToVtex(url.pathname)) return null;
|
|
704
|
+
return proxy(req, url);
|
|
705
|
+
};
|
|
706
|
+
export default createDecoWorkerEntry(serverEntry, { admin: {}, proxyHandler });
|
|
707
|
+
`;
|
|
708
|
+
const fs = makeFs({
|
|
709
|
+
"/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
|
|
710
|
+
"/site/src/worker-entry.ts": shorthandWorkerEntry,
|
|
711
|
+
});
|
|
712
|
+
const report = runAudit(SITE, fs);
|
|
713
|
+
const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
|
|
714
|
+
expect(r.findings).toEqual([]);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it("flags VTEX site that imports proxy helpers but never wires proxyHandler", () => {
|
|
718
|
+
const fs = makeFs({
|
|
719
|
+
"/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
|
|
720
|
+
"/site/src/worker-entry.ts": `
|
|
721
|
+
import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
|
|
722
|
+
import { shouldProxyToVtex } from "@decocms/apps/vtex/utils/proxy";
|
|
723
|
+
// note: shouldProxyToVtex imported but not used in a proxyHandler.
|
|
724
|
+
export default createDecoWorkerEntry(serverEntry, { admin: {} });
|
|
725
|
+
`,
|
|
726
|
+
});
|
|
727
|
+
const report = runAudit(SITE, fs);
|
|
728
|
+
const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
|
|
729
|
+
expect(r.findings).toHaveLength(1);
|
|
730
|
+
expect(r.findings[0].message).toContain("no `proxyHandler:` is wired");
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
|
|
631
734
|
describe("internals", () => {
|
|
632
735
|
it("extractExports parses common forms (top-level, unindented)", () => {
|
|
633
736
|
const code = [
|
package/scripts/migrate.ts
CHANGED
|
@@ -255,6 +255,19 @@ function bootstrap(ctx: { sourceDir: string }) {
|
|
|
255
255
|
const pm = "bun";
|
|
256
256
|
if (!run(`${pm} install`, "Install dependencies", true)) return;
|
|
257
257
|
run("bunx tsx node_modules/@decocms/start/scripts/generate-blocks.ts", "Generate CMS blocks");
|
|
258
|
+
// generate-invoke emits src/server/invoke.gen.ts with top-level
|
|
259
|
+
// createServerFn declarations + the forwardResponseCookies bridge that
|
|
260
|
+
// propagates VTEX Set-Cookie headers (orderFormId, segment, sc…) to the
|
|
261
|
+
// browser. Without this file, the site falls back to the proxy
|
|
262
|
+
// `~/runtime.ts` route which hits /deco/invoke and used to drop cookies,
|
|
263
|
+
// making the cart appear empty at /checkout after addItemToCart. The
|
|
264
|
+
// upstream invoke handler now also forwards cookies correctly, but
|
|
265
|
+
// running the generator gives every freshly-migrated site the canonical
|
|
266
|
+
// RPC path so VTEX hooks (useCart, useUser, useWishlist) work end-to-end.
|
|
267
|
+
run(
|
|
268
|
+
"bunx tsx node_modules/@decocms/start/scripts/generate-invoke.ts",
|
|
269
|
+
"Generate VTEX invoke server functions",
|
|
270
|
+
);
|
|
258
271
|
run("bunx tsr generate", "Generate TanStack routes");
|
|
259
272
|
|
|
260
273
|
if (failures > 0) {
|