From 0893d03e24d3011622d9f705fd10830cfc2a10dc Mon Sep 17 00:00:00 2001 From: marcoferreiradev Date: Wed, 10 Jun 2026 14:38:26 -0300 Subject: [PATCH 1/4] feat(website): native ?renderJson support in the fresh handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fresh.ts gains a structured-JSON exit alongside the existing ?asJson one: ?renderJson (legacy alias ?appJson — remove once consumers switch) resolves the page with hooks that short-circuit sections opted out of JSON rendering (their loaders never run), serializes the tree via @deco/deco serializeResolvedSection honoring each section's `renderJson` export, and responds { name, path, sections } with lazy sections as { component, lazyUrl } placeholders. One-shot JSON responses never use async render (firstByteThreshold guards extended); renderJson takes precedence when both params are sent; legacy ?asJson is untouched. The website app gains `renderJson.sectionsToIgnore` (admin-configurable): app-owned sections excluded by resolveType suffix — site-owned sections should prefer `export const renderJson = false` in their own file. Replaces the site-level wrapper pattern (handler + pages-loader fork) that oficina-reserva ran as v1 — the default website/loaders/pages.ts now works unchanged for renderJson consumers. Co-Authored-By: Claude Fable 5 --- website/handlers/fresh.ts | 95 ++++++++++++++++++++++++++++++++++++--- website/mod.ts | 11 +++++ 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/website/handlers/fresh.ts b/website/handlers/fresh.ts index 7e61896a8..d274c6b70 100644 --- a/website/handlers/fresh.ts +++ b/website/handlers/fresh.ts @@ -8,9 +8,14 @@ import { allowCorsFor, asResolved, type BaseContext, + computeRenderCb, type DecoState, isDeferred, RequestContext, + type ResolvedSection, + sectionModuleLookup, + type SerializeContext, + serializeResolvedSection, } from "@deco/deco"; import type { Exception } from "npm:@opentelemetry/api@1.9.0"; @@ -100,6 +105,7 @@ export default function Fresh( | "firstByteThresholdMS" | "isBot" | "flavor" + | "renderJson" >, ) { return async (req: Request, ctx: ConnInfo) => { @@ -111,6 +117,13 @@ export default function Fresh( const url = new URL(req.url); const startedAt = Date.now(); const asJson = url.searchParams.get("asJson"); + // Structured JSON rendering (sections serialized honoring the section's + // `renderJson` export). `appJson` is the legacy param name — remove once + // consumers switch to `renderJson`. + const renderJson = url.searchParams.has("renderJson") || + url.searchParams.has("appJson"); + // One-shot JSON responses (asJson/renderJson) never use async render. + const isJsonOneShot = asJson !== null || renderJson; const delayFromProps = appContext.firstByteThresholdMS ? 1 : 0; const delay = Number(url.searchParams.get(__DECO_FBT) ?? delayFromProps); /** Controller to abort third party fetch (loaders) */ @@ -128,7 +141,7 @@ export default function Fresh( * 2. Async Rendering Feature is activated * 3. Is not a bot (bot requires the whole page html for boosting SEO) */ - const firstByteThreshold = !asJson && delay && !appContext.isBot + const firstByteThreshold = !isJsonOneShot && delay && !appContext.isBot ? (delay === 1 ? (() => { console.warn( @@ -152,7 +165,7 @@ export default function Fresh( : undefined; // Propagate client aborts to loaders only when async render is enabled - if (!asJson && delay && !appContext.isBot) { + if (!isJsonOneShot && delay && !appContext.isBot) { abortCtrl = abortHandler(ctrl, req.signal, { url: `${url.pathname}${url.search}`, startedAt, @@ -165,6 +178,16 @@ export default function Fresh( registerFinilizer(req, abortCtrl); } try { + // Section drop for renderJson = consumer override (app-owned sections + // configured in the website app props) + the section's own + // `renderJson === false` export. + const getSectionModule = renderJson ? await sectionModuleLookup() : null; + const sectionsToIgnore = appContext.renderJson?.sectionsToIgnore ?? []; + const isDroppedSection = (resolveType: string) => + !!getSectionModule && + (sectionsToIgnore.some((suffix) => resolveType.endsWith(suffix)) || + getSectionModule(resolveType)?.renderJson === false); + const getPage = RequestContext.bind( { signal: ctrl.signal }, async () => @@ -177,13 +200,40 @@ export default function Fresh( ? await freshConfig.page({ context: ctx }, { propagateOptions: true, hooks: { - onPropsResolveStart: (resolve, _props, resolver) => { + // Dropped sections short-circuit child resolution (their + // prop-loaders never run) — see deco engine/core/resolver.ts. + onPropsResolveStart: ( + resolve, + _props, + resolver, + resolveType, + ) => { + if (renderJson && isDroppedSection(resolveType)) { + return Promise.resolve([]); + } let next = resolve; if (resolver?.type === "matchers") { // matchers should not have a timeout. next = RequestContext.bind({ signal: req.signal }, resolve); } return next(); }, + // Skip the dropped section's own inline `mod.loader` by + // stubbing before the section resolver runs. Cast via + // `unknown` because the hook is generic over T. + onResolveStart: ( + proceed: () => Promise, + _props: T, + _resolver: unknown, + resolveType: string, + ): Promise => { + if (renderJson && isDroppedSection(resolveType)) { + const stub: ResolvedSection = { + metadata: { component: resolveType }, + }; + return Promise.resolve(stub as unknown as T); + } + return proceed(); + }, }, }) : freshConfig.page, @@ -196,7 +246,7 @@ export default function Fresh( if (pathTemplate) { span?.setAttribute?.("deco.path_template", pathTemplate); } - if (delay && !asJson && !appContext.isBot) { + if (delay && !isJsonOneShot && !appContext.isBot) { span?.setAttribute?.( "deco.async_render.first_byte_threshold_ms", delay, @@ -232,6 +282,41 @@ export default function Fresh( } }, ); + // renderJson takes precedence over asJson when both params are sent. + if (renderJson) { + didFinish = true; + const pageProps = (page as unknown as Record)?.props as + | Record + | undefined; + // appContext runtime spreads state.global (= the full request state), + // so vary/revision/release/deco are reachable here even though the + // type declares a narrower surface. + // deno-lint-ignore no-explicit-any + const appCtx = appContext as any; + const cb = computeRenderCb({ + revision: appCtx?.revision ?? (await appCtx?.release?.revision?.()), + vary: appCtx?.vary?.build?.(), + href: req.url, + deploymentId: appCtx?.deco?.ctx?.deploymentId, + }); + const serializeCtx: SerializeContext = { + href: req.url, + pathTemplate: pathTemplate ?? url.pathname, + cb, + getSectionModule: getSectionModule ?? undefined, + }; + const sections = ((pageProps?.sections as Array) ?? []) + .map((section) => serializeResolvedSection(section, serializeCtx)) + // Drops renderJson === false sections (serialized to null) and the + // stubs left by onResolveStart for the ignored list. + .filter((s): s is NonNullable => + s !== null && !isDroppedSection(s.component) + ); + return Response.json( + { name: pageProps?.name, path: pageProps?.path, sections }, + { headers: allowCorsFor(req) }, + ); + } if (asJson !== null) { didFinish = true; return Response.json(page, { headers: allowCorsFor(req) }); @@ -264,7 +349,7 @@ export default function Fresh( pagePath: ctx.state.pathTemplate, }, }); - if (!asJson && delay && !appContext.isBot) { + if (!isJsonOneShot && delay && !appContext.isBot) { console.info( `[fresh][async-render-response] returned initial HTML with async render` + ` url=${url.pathname}${url.search}` + diff --git a/website/mod.ts b/website/mod.ts index 8c9ca6269..25339abe9 100644 --- a/website/mod.ts +++ b/website/mod.ts @@ -116,6 +116,17 @@ export interface Props { * @default false */ firstByteThresholdMS?: boolean; + /** + * @title renderJson + * @description Options for the structured JSON rendering of pages (?renderJson) + */ + renderJson?: { + /** + * @title Ignored Sections + * @description App-owned sections excluded from the renderJson response, matched by resolveType suffix (e.g. "SeoV2.tsx"). Site-owned sections should prefer `export const renderJson = false` in their own file. + */ + sectionsToIgnore?: string[]; + }; /** * @title Avoid redirecting to editor * @description Disable going to editor when "." or "Ctrl + Shift + E" is pressed From a84e9f5e5d2416e18b6d6aba6a33e9ebab5d755a Mon Sep 17 00:00:00 2001 From: marcoferreiradev Date: Wed, 10 Jun 2026 15:32:10 -0300 Subject: [PATCH 2/4] refactor(website): drop the ?appJson legacy alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderJson is the replacement, not a sibling — consumers adopt ?renderJson directly (validation happens on PR previews, nothing in production speaks ?appJson). Only the legacy ?asJson remains as a separate, untouched mode. Co-Authored-By: Claude Fable 5 --- website/handlers/fresh.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/website/handlers/fresh.ts b/website/handlers/fresh.ts index d274c6b70..c8d3224d8 100644 --- a/website/handlers/fresh.ts +++ b/website/handlers/fresh.ts @@ -118,10 +118,8 @@ export default function Fresh( const startedAt = Date.now(); const asJson = url.searchParams.get("asJson"); // Structured JSON rendering (sections serialized honoring the section's - // `renderJson` export). `appJson` is the legacy param name — remove once - // consumers switch to `renderJson`. - const renderJson = url.searchParams.has("renderJson") || - url.searchParams.has("appJson"); + // `renderJson` export). + const renderJson = url.searchParams.has("renderJson"); // One-shot JSON responses (asJson/renderJson) never use async render. const isJsonOneShot = asJson !== null || renderJson; const delayFromProps = appContext.firstByteThresholdMS ? 1 : 0; From 99630917ad1d91bff4716b6ea38973e28f38466f Mon Sep 17 00:00:00 2001 From: marcoferreiradev Date: Thu, 18 Jun 2026 12:23:24 -0300 Subject: [PATCH 3/4] fix(website): trim and drop blank renderJson ignore suffixes A blank entry in renderJson.sectionsToIgnore made resolveType.endsWith("") match every section, so ?renderJson could drop an entire page. Trim each configured suffix and discard empties before the isDroppedSection check. Co-Authored-By: Claude Opus 4.8 (1M context) --- website/handlers/fresh.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/website/handlers/fresh.ts b/website/handlers/fresh.ts index c8d3224d8..00dd0970b 100644 --- a/website/handlers/fresh.ts +++ b/website/handlers/fresh.ts @@ -180,7 +180,11 @@ export default function Fresh( // configured in the website app props) + the section's own // `renderJson === false` export. const getSectionModule = renderJson ? await sectionModuleLookup() : null; - const sectionsToIgnore = appContext.renderJson?.sectionsToIgnore ?? []; + // Trim and drop blank suffixes — an empty entry would make `endsWith("")` + // match every section and drop the whole page. + const sectionsToIgnore = (appContext.renderJson?.sectionsToIgnore ?? []) + .map((suffix) => suffix.trim()) + .filter((suffix) => suffix.length > 0); const isDroppedSection = (resolveType: string) => !!getSectionModule && (sectionsToIgnore.some((suffix) => resolveType.endsWith(suffix)) || From 75cdee29b33a5668501d54bd6f9f5f02c3ac99dd Mon Sep 17 00:00:00 2001 From: marcoferreiradev Date: Thu, 18 Jun 2026 12:36:47 -0300 Subject: [PATCH 4/4] fix(website): harden renderJson ignore-suffix normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sectionsToIgnore comes from admin config (external JSON) and is not type-guaranteed at runtime. The previous `.map(s => s.trim())` would throw a TypeError (500) on a non-string entry — the old `endsWith` tolerated it via coercion. Filter to strings before trimming, and keep only non-empty suffixes (a blank entry would make endsWith("") drop the whole page). Co-Authored-By: Claude Opus 4.8 (1M context) --- website/handlers/fresh.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/website/handlers/fresh.ts b/website/handlers/fresh.ts index 00dd0970b..769323905 100644 --- a/website/handlers/fresh.ts +++ b/website/handlers/fresh.ts @@ -180,11 +180,15 @@ export default function Fresh( // configured in the website app props) + the section's own // `renderJson === false` export. const getSectionModule = renderJson ? await sectionModuleLookup() : null; - // Trim and drop blank suffixes — an empty entry would make `endsWith("")` - // match every section and drop the whole page. - const sectionsToIgnore = (appContext.renderJson?.sectionsToIgnore ?? []) - .map((suffix) => suffix.trim()) - .filter((suffix) => suffix.length > 0); + // sectionsToIgnore is admin config (external JSON), so it is not + // type-guaranteed at runtime. Keep only non-empty string suffixes: a + // blank entry would make `endsWith("")` match every section (dropping the + // whole page), and a non-string would throw on `.trim()` (500ing the request). + const sectionsToIgnore = + ((appContext.renderJson?.sectionsToIgnore ?? []) as unknown[]) + .filter((suffix): suffix is string => typeof suffix === "string") + .map((suffix) => suffix.trim()) + .filter((suffix) => suffix.length > 0); const isDroppedSection = (resolveType: string) => !!getSectionModule && (sectionsToIgnore.some((suffix) => resolveType.endsWith(suffix)) ||