Skip to content

chore: sync upstream deco-cx/deco 1.211.2 into main-alt - #3

Merged
marcoferreiradev merged 29 commits into
main-altfrom
chore/sync-upstream-2026-09-30
Sep 30, 2026
Merged

marcoferreiradev merged 29 commits into
main-altfrom
chore/sync-upstream-2026-09-30

Conversation

@marcoferreiradev

Copy link
Copy Markdown
Collaborator

O que é

Merge (merge commit) de deco-cx/deco@main (1.211.2, 28 commits) em main-alt. Base comum: 1.203.0 (40762b5).

O que vem do upstream

Revisão contra as nossas regras

  • Merge textual limpo. Arquivos tocados pelos dois lados: engine/core/resolver.ts e hooks/useSection.ts — hunks disjuntos.
  • 482c0b0 (cache do loader por módulo, base do cache compartilhado site × app): intacto. O memo do upstream é por request e não entra na chave do cache de loader (blocks/loader.ts segue resolver: blockKey).
  • renderJson / computeRenderCb: byte-idênticos; /deco/render gera a mesma URL (o __draft só entra com rascunho ativo).
  • Sessão / cookies: único cookie novo é __deco_draft (HttpOnly, só com ?__draft= em host permitido); stripFrameworkSetCookies no mesmo gate.
  • Testes: engine/core, hooks, blocks, utils/userAgent — 12 passed (22 steps), deno 2.8.2.

Merge: merge commit (não squash) — o site pina o SHA deste branch.

🤖 Generated with Claude Code

Nicacio Oliveira and others added 29 commits August 4, 2026 13:15
This wrapper already measured the duration of every outgoing fetch — it just
discarded it unless a logger happened to be installed, and `logger` is null in
production:

    const start = logger && performance.now();

So there was no metric answering "is the external API slow, or are we making
too many calls to it?". The only alternative was `otel_traces`, which is
tail-sampled at ~1.7% and carries no client spans for these calls at all. In
practice that meant a `load-data` span of 157s could not be attributed to
anything, and diagnosis fell back to guessing.

Adds an `outgoing_fetch_duration` histogram, dimensioned by external host and
status class.

Notes on the design, both copied from what already works in this repo:

- `unit: "ms"`. The meter provider in `observability/otel/metrics.ts` selects
  bucket boundaries by unit, so "ms" picks up
  `[10, 100, 500, 1000, 5000, 10000, 15000]` automatically. Recording seconds
  would put every observation in the first bucket — which is exactly the bug
  the @decocms/start runtime currently has on its four duration metrics
  (99.8%-99.95% in bucket 1, measured).

- Low cardinality by construction: `server.address` is the host, never the path,
  and status is bucketed into a class rather than the raw code. Measured on a
  large VTEX storefront, a site talks to 6 distinct hosts, so this is ~6 x 5
  series per site. For comparison, `loader_cache` reaches 3684 distinct label
  values on a single site because it uses the full resolver chain as a label.

- Failures are recorded, not dropped. A call that hangs for 60s and then aborts
  is the sample you most want and the one a success-only path loses. The error
  is re-thrown untouched.

- `hostOf` returns null instead of throwing on a malformed input, and a null
  host skips the sample. A metric must not be able to break the fetch path.

The logger behaviour is unchanged.

Verified: `deno check runtime/fetch/fetchLog.ts` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`URL.host` appends a non-default port, so `example.com:8080` and `example.com`
would become two distinct `server.address` label values for the same host —
inflating exactly the cardinality this metric is careful about, and undercutting
the "6 hosts x 5 classes per site" claim in its own docstring.

`hostname` also matches semconv, where `server.address` is the address alone and
`server.port` is a separate attribute.

Caught by CodeRabbit on deco-cx#1218.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Authority-less schemes — `data:`, `blob:`, `file:` — parse fine but have no
hostname, so they returned "" and `record` only skipped on null. That would have
created a meaningless `server.address=""` series for calls that never crossed
the network.

Also collapses the three-branch return into a single URL construction.

Verified:
  https://a.com/x         -> a.com
  https://a.com:8080/x    -> a.com
  data:text/plain,hi      -> null
  file:///tmp/x           -> null
  nonsense                -> null

Caught by cubic on deco-cx#1218.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
deco-cx#1222)

* feat(preview): sandbox-less Fast Preview via pull-based draft decofile

Port of decocms/blocks#463 to the Deno runtime. A production site can render
a draft by PULLING the draft decofile from Studio's decofile API and rendering
its own real pages against it, as a request-scoped snapshot — no sandbox daemon.

- engine/decofile/draft.ts: `?__draft=<authority><path>?token=…@<version>`
  pointer parsing (last-`@` split), authority validation against preview API
  domains (scheme derived, no SSRF), host gating (DECO_ALLOWED_PREVIEW_HOSTS /
  site-declared previewHosts), version-bounded cache, cookie helper.
- runtime/mod.ts (prepareState): the single per-request seam covering all
  routes. When a draft resolves, swap `state.release` for `fromJSON(draft)` +
  rebind the resolver (`resolver.with({release})`) — snapshot semantics for
  free (deletions work). Force no-store + noindex; persist the pointer cookie
  so /deco/render and /deco/invoke partials carry the same draft.
- engine/mod.ts: re-export the public API.
- engine/decofile/draft.test.ts: 9 tests / 31 steps mirroring the Node suite.

Verified end-to-end against a real Fresh site (demo-linkedin): draft renders
the pulled snapshot, gates (host/origin/off/cookie-nav) all behave, published
traffic is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(preview): key draft cache by host+path+version; harden cookie decode

Addresses review of deco-cx#1222:
- draftSource cache was keyed by `version` alone; a runtime pointed at more
  than one draft source that reuse a version label (`v1`, a branch id) could
  serve the wrong snapshot. Key on `<host><path>@<version>` so a hit is always
  the right source. `version` is a sha in production, but the type accepts any
  short label — so this closes a real collision class.
- `readCookieValue` now degrades a malformed `%`-sequence cookie to null
  instead of letting `decodeURIComponent` throw at direct callers of the
  exported `draftPointerFromRequest`.
- Tests: cross-source cache non-collision + malformed-cookie null (33 steps).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(preview): draft badge — always-on "you are viewing a draft" signal

Follow-up to Fast Preview (deco-cx#1222). Fast Preview carries the draft pointer in a
cookie across navigation, so after the first click the URL no longer shows
`?__draft=` — a reviewer can forget they're on unpublished content. This adds a
floating badge whenever a draft is bound, with "leave preview" and "copy a link
to this exact draft version".

- runtime/draftBadge.ts: self-contained HTML+JS snippet (inline styles, no site
  CSS/island dependency, very high z-index), on-brand (deco lime/green). Renders
  nothing-first and reveals only once confirmed unframed — hidden inside
  Studio's own preview iframe, never flashes. Pointer embedded only in the
  copy-link handler (script-escaped), never as raw HTML.
- runtime/mod.ts (prepareState): stash the active pointer in the request bag
  (server-side; the cookie is HttpOnly) when a draft resolves.
- runtime/middleware.ts: inject the badge before </body> on draft-bound HTML
  responses, alongside the existing cookie-script injection (single body buffer).
- runtime/draftBadge.test.ts: builder + injector unit tests (escaping, iframe
  gate, first-</body> injection).

Verified in-browser against demo-linkedin: badge floats bottom-center on a
draft render, absent on published traffic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(preview): match the Node draft badge exactly (pill + popover, deco mark)

Rework the badge to be a faithful vanilla port of @decocms/blocks's React
DraftPreviewBadge instead of the ad-hoc pill:
- bottom-left "Preview mode" pill with the inlined deco mark (runtime/decoMark.ts,
  self-contained data URI — no asset pipeline / 404 risk).
- click opens a white popover above it with "Exit preview" (arrow icon) and
  "Share preview" (three-node icon, → "Copied!"), matching colours, paddings,
  radii and shadows.
- outside-click / Escape dismissal; exit → ?__draft=off; share copies
  ?__draft=<pointer> (prompt fallback when clipboard is blocked).
- English copy, same as Node.

Verified in-browser against demo-linkedin: pill bottom-left, popover on click.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…tion-metric

feat(fetch): record outgoing fetch duration by external host
…eco-cx#1225)

Fast Preview renders the page shell against the draft, but deferred
sections are lazy-loaded client-side via a separate `/deco/render`
fetch that resolves its draft from the `__deco_draft` cookie. That
cookie is `SameSite=Lax`, so inside Studio's cross-site preview iframe
the browser does not attach it to the subrequest — the section renders
against the PUBLISHED release while the shell rendered the draft,
producing duplicated or stale sections (a removed section reappears).
Opening the same URL in a top-level tab works because the cookie is
sent in first-party context.

`useSection` runs server-side and the runtime already stashes the
active pointer in the request bag during `prepareState`. Carry it
forward as an explicit top-level `?__draft=` param on the render URL:
`draftPointerFromRequest` prefers the param over the cookie, so lazy
rendering stays on the draft regardless of frame context. The param is
only appended when a draft is actually bound, so ordinary traffic is
untouched.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
deco-cx#1224)

* feat(preview): infer <site>.deco.site as an allowed draft preview host

The draft-preview allowlist (DECO_ALLOWED_PREVIEW_HOSTS / site-block
previewHosts) required per-site opt-in, but DECO_SITE_NAME isn't always
in the env. Derive the deco-hosted preview domain from the site name the
runtime already resolves (opts.site ?? DECO_SITE_NAME ?? …) and register
it via setDecoSiteHost at Deco.init.

<site>.deco.site is merged ON TOP of the env/site-block list (never
replacing it) so a signed draft grant can preview on deco-operated infra
out of the box. The random dev fallback registers nothing, and a custom
production domain is never inferred — it stays inert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(preview): add kill switch and document threat model for inferred host

Address review of the `<site>.deco.site` inference:

- `DECO_ALLOWED_PREVIEW_HOSTS=none` is now a kill switch that disables
  preview entirely (inferred host + site block included), restoring the
  env var's "stop a bad rollout without a deploy" escape hatch — which the
  merge-on-top had removed for the inferred host.
- Document the post-change threat model: a named site is no longer inert
  by default, the request host is spoofable, so the signed `?__draft=`
  grant is the sole remaining gate; host-scoping only bounds blast radius.
- Fix the now-stale `isDraftPreviewEnabled` docstring.
- Tests: kill switch, and undefined (random dev fallback) registering no host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…eco-cx#1227)

The `deviceHint` query param is an explicit override, only ever present
when a tool (e.g. the Studio preview device toggle) forces a device. Real
client traffic never carries it, yet `deviceOf` evaluated `cf-device-type`
first — so behind Cloudflare the requesting browser's real device (desktop)
always won and the forced `deviceHint=mobile` was silently ignored, both in
sandbox preview and Fast Preview.

Move `deviceHint` to the top of the resolution chain so the override is
honored when present; production traffic (no param) still falls through to
cf-device-type / user-agent unchanged.

Adds utils/userAgent.test.ts covering the priority order.
…aft preview host (deco-cx#1226)

Extends the deco-hosted preview inference (<site>.deco.site, deco-cx#1224) to the
per-deploy preview CDN. Unlike the stable .deco.site domain, the deploy URL
carries a fresh <hash> label every deploy (envs-als-storefront--4l18ts.decocdn.com),
so it can't be a fixed allowlist entry — it's matched as a PATTERN instead.

- setDecoSiteHost now stores the raw site name and derives both deco-operated
  hosts from it: <site>.deco.site (exact) and envs-<site>--<hash>.decocdn.com
  (pattern).
- previewConfig centralises the exact-host list, the deploy-preview prefix, and
  the kill switch in one read.
- matchesDeployPreview constrains <hash> to a SINGLE DNS label (no dots), so
  nothing under an attacker-controlled *.decocdn.com subdomain
  (envs-<site>--x.evil.decocdn.com) can widen the match.

Merged ON TOP of the env/site-block list, killed by DECO_ALLOWED_PREVIEW_HOSTS=none,
and only inferred for a named site — same guarantees as the .deco.site host.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…emo (deco-cx#1230)

Resolvables (named decofile blocks) are memoized per request by key in
`context.memo`. The cached value is the *promise* returned by the first
consumer to resolve that key, and every other reference to the block awaits
that same promise.

`website/sections/Rendering/Lazy.tsx` resolves its inner section under an
already-aborted `AbortController` to render a loading fallback fast. If a
section resolved this way is a named block that is also referenced elsewhere
on the page, the aborted resolution's rejected promise gets cached and served
to those other references — they all fail with `AbortError` and the surrounding
render collapses to empty.

This surfaces on multivariate pages (`website/flags/multivariate.ts`): adding a
second page variant is enough to change resolution ordering/concurrency so the
aborted lazy read wins the memo race, and the page renders blank ~80% of the
time (flaky, since it's a race). The abort belongs to one consumer, not to the
block, so it must not be cached.

Fix: when a memoized resolvable rejects with an abort, evict the cache entry so
later reads re-resolve; and if a consumer whose own signal is still live
inherited a co-scheduled aborted promise, re-resolve fresh under that live
caller. Consumers that are themselves aborted still reject, as before.

Adds sequential and concurrent regression tests.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…cx#1232)

Resolve hints are cached by resolveType (block id) and derived from the
release's resolvables (`context.resolveHints[resolveType] ??= traverseAny(...)`
in engine/core/resolver.ts). Fast Preview binds a request-scoped draft by
swapping the release via `resolver.with({ release: draftProvider })`, but
`.with` copied the base resolver's hints (`{ ...this.resolveHints }`).

On a long-running server that serves the published release, the hint cache for
a block gets populated with the published shape. When the draft is then
resolved, it reuses those stale hints — so a block whose shape changed between
releases (e.g. a page whose `sections` was a plain array when published but a
`website/flags/multivariate` flag in the draft) resolves against the old shape
and silently drops everything the old hints don't cover. Symptom: the draft
renders a blank page (only globally-injected sections survive; the whole page
body, footer included, is gone), while a fresh process that saw the draft first
renders correctly.

Fix: when `.with` swaps the release, start from empty hints instead of
inheriting them. Hints belong to a release's resolvables, so a different
release invalidates them — this mirrors the `release.onChange` invariant in the
constructor, which already clears hints on every release change. Only the two
preview/release-swap call sites are affected (Fast Preview draft, block
preview); normal traffic and the setup path (which pass no `release`) are
unchanged.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…1233)

Newer Deno (the CI pulls `deno-version: v2.x`) rejects a bare `file:` as a
reload specifier: `error: invalid reload URL: 'file:'`, failing the "Setup
deno" job on every run. The flag is also redundant — the preceding "Build Deno
Module" step already runs `deno run --reload mod.ts`, so all deps are cached by
the time this step runs; `deno cache ./mod.ts` just fetches anything still
missing, which matches the step name.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
deco-cx#1234)

* fix(engine): dispose superseded ReleaseResolver's release subscription

Every `new ReleaseResolver` subscribes to its release in the constructor and
the returned Disposable was dropped. `installApps` rebuilds the resolver on
each decofile change (`currentResolver = currentResolver.with(...)`), so every
publish left one more superseded resolver permanently reachable from the
provider's listener list, retaining its whole resolvables/resolvers/
resolveHints graph.

Measured on a production replica (same runner image, Deno 1.44.4, real
decofile): 8.5 MB of heap retained per publish, after two forced GCs, linear
over 10 reloads — same with concurrent load (8.8 MB). A site publishing 24-36
times a day accumulated ~300 MB of unreclaimable heap in 44h.

Keep the Disposable, expose `dispose()` (idempotent, also via Symbol.dispose),
and drop the superseded resolver's subscription in `installApps`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(decofile): make the resolver's release subscription actually disposable

The previous commit kept the Disposable returned by `release.onChange` and
dropped it when a resolver was superseded, but that was inert in production
and incomplete across publishes. Review of deco-cx#1234 surfaced both:

- `fromEndpoint` (engine/decofile/fetcher.ts) subscribed through
  `decofileProviderPromise.then((r) => r.onChange(cb))` and returned a
  disposable whose `[Symbol.dispose]` was empty, discarding the real inner
  subscription. `getProvider()` wraps every `folder://`, `file://`,
  `deconfig://` and `http(s)://` release in it, and `compose()` returns a
  single provider unchanged, so every real deployment disposed nothing.
  It now proxies the eventual subscription, including disposal that happens
  before the provider promise settles.

- `fulfillContext` rebuilds the runtime resolver from the same stable base on
  every publish and overwrites `ctx.runtime` without disposing the outgoing
  one, so one resolver graph per publish stayed reachable — the very scenario
  the fix targets. Track the installed resolver and dispose it once its
  replacement is live. On the zero-apps path `runtimePromise` only takes the
  first resolution, so a later publish there discards the resolver it just
  built: dispose that one instead.

Also guard `splice(indexOf(cb), 1)` in the four `onChange` implementations
(fs, fsFolder, realtime, fromJSON): now that disposal actually runs, a second
call would find -1 and drop the last, still-live callback. `Symbol.dispose`
becomes an alias of the bound `dispose` field instead of a prototype method
that breaks when detached, and two dead optional chains are removed.

The new test counts live subscriptions through `fromEndpoint` by way of the
`deco:hmr` event; it fails (26 live subscriptions instead of 1) without the
fetcher fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…deco-cx#1238)

* feat(draft): allow fast preview on local dev hosts and the dev tunnel

The `?__draft=` fast preview was inert during local development: it only
rendered drafts on hosts named in `previewHosts` / `DECO_ALLOWED_PREVIEW_HOSTS`
or the deco-hosted `<site>.deco.site` / `envs-<site>--<hash>.decocdn.com`
domains. But `deno task start` serves on `localhost:<port>` and on the
per-developer dev tunnel `<env>--<site>.deco.{host,site}` (daemon/tunnel.ts),
so a draft never previewed locally without extra config.

Now local dev hosts (`localhost`, `127.0.0.1`, `*.localhost`, any port) are
always allowed, and the dev tunnel is inferred from the resolved site name with
its `<env>` label pinned to a single DNS label — the same anti-widening
constraint as the per-deploy `<hash>`. `DECO_ALLOWED_PREVIEW_HOSTS=none` still
kills everything, local and tunnel included. The signed `?__draft=` grant
remains the actual capability; host-scoping only bounds blast radius.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(draft): address review — drop .deco.site tunnel apex, match IPv6 loopback

Two issues from PR review:

1. The dev-tunnel match no longer accepts the `<env>--<site>.deco.site`
   simpletunnel fallback: `.deco.site` is also the stable production apex, so
   matching it widened the draft gate on production, not just dev machines. Only
   `<env>--<site>.deco.host` (the `DECO_HOST` default) is matched now; the rare
   `DECO_HOST=false` opt-out previews via localhost or an explicit allowlist.

2. `isLocalDevHost` now treats the IPv6 loopback as local — `::1`, `[::1]`, and
   `[::1]:port` — which the port-splitting previously mangled to a non-match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@marcoferreiradev
marcoferreiradev merged commit c30a0f6 into main-alt Sep 30, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants