diff --git a/hooks/mod.ts b/hooks/mod.ts index 6de76d44a..7c0635b73 100644 --- a/hooks/mod.ts +++ b/hooks/mod.ts @@ -8,6 +8,7 @@ export { addAllowedQS, addBlockedQS, addBlockedQS as unstable_blockUseSectionHrefQueryStrings, + computeRenderCb, useSection, } from "./useSection.ts"; export { useSetEarlyHints } from "./useSetEarlyHints.ts"; diff --git a/hooks/useSection.ts b/hooks/useSection.ts index cfbe5d0f6..bab1c2255 100644 --- a/hooks/useSection.ts +++ b/hooks/useSection.ts @@ -94,6 +94,36 @@ const createStableHref = (href: string): string => { return hrefUrl.href; }; +export interface RenderCbInput { + revision: unknown; + vary: unknown; + href: string; + deploymentId?: string; +} + +/** + * Cache-bust value for `/deco/render` URLs. Single source of truth shared by + * `useSection` (web partials) and JSON serialization consumers (e.g. the + * website app's ?renderJson handler) — both sides MUST produce identical + * values or web/JSON cache invalidation diverges. + * + * `revision`/`vary` join as-is (undefined → "undefined") to preserve the + * historical recipe byte-for-byte. Shared `hasher` is safe ONLY because this + * function is fully synchronous — no await between hash() and reset(). + */ +export const computeRenderCb = (input: RenderCbInput): string => { + const cbString = [ + input.revision, + input.vary, + createStableHref(input.href), + input.deploymentId, + ].join("|"); + hasher.hash(cbString); + const cb = `${hasher.result()}`; + hasher.reset(); + return cb; +}; + export type Options

= { /** Section props partially applied */ props?: Partial

? K : P>; @@ -119,15 +149,12 @@ export const useSection =

( const hrefParam = href ?? request.url; const stableHref = createStableHref(hrefParam); - const cbString = [ - revisionId, + const cb = computeRenderCb({ + revision: revisionId, vary, - stableHref, - ctx?.deploymentId, - ].join("|"); - hasher.hash(cbString); - const cb = hasher.result(); - hasher.reset(); + href: hrefParam, + deploymentId: ctx?.deploymentId, + }); const params = new URLSearchParams([ ["props", JSON.stringify(props)], diff --git a/mod.ts b/mod.ts index a4c9bb4e6..cb801561a 100644 --- a/mod.ts +++ b/mod.ts @@ -45,6 +45,21 @@ export { type DecoRouteState } from "./runtime/middleware.ts"; export * from "./runtime/mod.ts"; export { Deco } from "./runtime/mod.ts"; export type { PageData } from "./runtime/mod.ts"; +export { + buildLazyUrl, + sectionModuleLookup, + serializeResolvedSection, +} from "./runtime/routes/serialize-section.ts"; +export { computeRenderCb } from "./hooks/useSection.ts"; +export type { RenderCbInput } from "./hooks/useSection.ts"; +export type { + LazyUrlContext, + RenderJson, + ResolvedSection, + SectionJsonModule, + SerializeContext, + SerializedSection, +} from "./runtime/routes/serialize-section.ts"; export * from "./types.ts"; export { allowCorsFor } from "./utils/http.ts"; export type { StreamProps } from "./utils/invoke.ts"; diff --git a/runtime/routes/render.tsx b/runtime/routes/render.tsx index 094312721..a36279730 100644 --- a/runtime/routes/render.tsx +++ b/runtime/routes/render.tsx @@ -8,6 +8,12 @@ import { singleFlight } from "../../utils/mod.ts"; import { createHandler, DEBUG_QS } from "../middleware.ts"; import type { PageParams } from "../mod.ts"; import Render, { type PageData } from "./entrypoint.tsx"; +import { + type ResolvedSection, + sectionModuleLookup, + type SerializeContext, + serializeResolvedSection, +} from "./serialize-section.ts"; interface Options { resolveChain?: FieldResolver[]; @@ -91,6 +97,30 @@ export const handler = createHandler(async ( if (isDebugRequest) { return Response.json({ debugData: state.vary.debug.build() }); } + + if (opts.searchParams.get("format") === "json") { + const serializeCtx: SerializeContext = { + href: opts.href, + pathTemplate: opts.pathTemplate, + renderSalt: opts.renderSalt, + cb: opts.searchParams.get("__cb") ?? undefined, + getSectionModule: await sectionModuleLookup(), + }; + const serialized = serializeResolvedSection( + page as ResolvedSection, + serializeCtx, + ); + const jsonResponse = Response.json(serialized); + const shouldCacheFromVary = ctx?.var?.vary?.shouldCache === true; + jsonResponse.headers.set( + "cache-control", + shouldCache && shouldCacheFromVary + ? DECO_RENDER_CACHE_CONTROL + : "no-store, no-cache, must-revalidate", + ); + return jsonResponse; + } + const props = { params: ctx.req.param(), url: ctx.var.url, diff --git a/runtime/routes/serialize-section.ts b/runtime/routes/serialize-section.ts new file mode 100644 index 000000000..d4c9d1ff8 --- /dev/null +++ b/runtime/routes/serialize-section.ts @@ -0,0 +1,202 @@ +import { Context } from "../../deco.ts"; +import { FieldResolver } from "../../engine/core/resolver.ts"; + +const LAZY_SECTION_PATH = "/Rendering/Lazy.tsx"; + +export interface ResolvedSection { + Component?: unknown; + props?: Record; + metadata?: { + component?: string; + resolveChain?: FieldResolver[]; + }; +} + +export type SerializedSection = + | { component: string; props: Record } + | { component: string; lazyUrl: string }; + +/** + * Module-level control of how a section renders to JSON. + * + * - `false` — the section has no JSON rendering; it is dropped from the + * serialized output entirely. + * - function — a projection applied to the resolved props before + * serialization. Annotate the parameter with `SectionProps` + * (or the section's own Props) to keep it compile-checked. + * + * ```ts + * export const renderJson = false; + * + * export const renderJson = ( + * { internalOnly, ...rest }: SectionProps, + * ) => rest; + * ``` + */ +export type RenderJson = + // deno-lint-ignore no-explicit-any + | ((props: any) => Record) + | false; + +export interface SectionJsonModule { + renderJson?: RenderJson; +} + +export interface LazyUrlContext { + href: string; + pathTemplate: string; + renderSalt?: string; + cb?: string; +} + +export interface SerializeContext extends LazyUrlContext { + /** + * Resolves a section module by its component name (resolveType) so the + * serializer can honor its `renderJson` export. When omitted, every + * section serializes with its full resolved props. + */ + getSectionModule?: (component: string) => SectionJsonModule | undefined; +} + +/** + * Builds a `getSectionModule` lookup over the active context's merged + * manifest (site + apps), keyed by resolveType. + */ +export const sectionModuleLookup = async (): Promise< + (component: string) => SectionJsonModule | undefined +> => { + const runtime = await Context.active().runtime; + const sections = (runtime?.manifest as unknown as { + sections?: Record; + })?.sections ?? {}; + return (component) => sections[component]; +}; + +export function buildLazyUrl( + resolveChain: FieldResolver[], + ctx: LazyUrlContext, +): string { + const params = new URLSearchParams([ + ["format", "json"], + ["props", JSON.stringify({ loading: "eager" })], + ["href", ctx.href], + ["pathTemplate", ctx.pathTemplate], + [ + "resolveChain", + JSON.stringify(FieldResolver.minify(resolveChain.slice(0, -1))), + ], + ]); + if (ctx.renderSalt) params.set("renderSalt", ctx.renderSalt); + if (ctx.cb) params.set("__cb", ctx.cb); + return `/deco/render?${params}`; +} + +function isSectionShape(value: unknown): value is ResolvedSection { + if (!value || typeof value !== "object") return false; + const meta = (value as ResolvedSection).metadata; + return typeof meta?.component === "string"; +} + +function isLazyComponent(component: string | undefined): boolean { + return !!component?.endsWith(LAZY_SECTION_PATH); +} + +function getInnerSection(node: ResolvedSection): ResolvedSection | undefined { + const inner = (node.props as { section?: unknown } | undefined)?.section; + return isSectionShape(inner) ? inner : undefined; +} + +function getLoading(node: ResolvedSection): string | undefined { + return (node.props as { loading?: string } | undefined)?.loading; +} + +function renderJsonOf( + ctx: SerializeContext, + component: string, +): RenderJson | undefined { + return ctx.getSectionModule?.(component)?.renderJson; +} + +/** + * Serializes a resolved section honoring its `renderJson` export. + * Returns `null` when the section opted out (`renderJson === false`) — + * callers must drop it from the output. + * + * A `renderJson` projection cannot run for lazy placeholders (their props + * are not resolved yet); it applies on the lazy fetch itself, when + * `/deco/render?format=json` serializes the resolved section. + */ +export function serializeResolvedSection( + node: ResolvedSection, + ctx: SerializeContext, +): SerializedSection | null { + let current = node; + while ( + isLazyComponent(current.metadata?.component) && + getLoading(current) === "eager" + ) { + const inner = getInnerSection(current); + if (!inner) break; + current = inner; + } + + if ( + isLazyComponent(current.metadata?.component) && + getLoading(current) === "lazy" + ) { + const inner = getInnerSection(current); + const component = inner?.metadata?.component ?? + current.metadata!.component!; + if (renderJsonOf(ctx, component) === false) return null; + return { + component, + // The wrapper's resolveChain is what reconstructs this slot on the lazy + // fetch — it re-resolves the inner section and applies the inner's + // renderJson there. The inner is not independently resolvable, so its own + // chain would not rebuild this position; the wrapper's chain is correct. + lazyUrl: buildLazyUrl(current.metadata!.resolveChain ?? [], ctx), + }; + } + + const component = current.metadata!.component!; + const renderJson = renderJsonOf(ctx, component); + if (renderJson === false) return null; + + const props = typeof renderJson === "function" + ? renderJson(current.props ?? {}) + : current.props ?? {}; + + return { + component, + props: walkValue(props, ctx) as Record, + }; +} + +function walkValue(value: unknown, ctx: SerializeContext): unknown { + if (Array.isArray(value)) { + // Dropped sections (renderJson === false) are removed from arrays so + // consumers iterate a dense list; non-section nulls pass through. + const out: unknown[] = []; + for (const v of value) { + if (isSectionShape(v)) { + const serialized = serializeResolvedSection(v, ctx); + if (serialized !== null) out.push(serialized); + } else { + out.push(walkValue(v, ctx)); + } + } + return out; + } + if (value && typeof value === "object") { + if (isSectionShape(value)) { + return serializeResolvedSection(value, ctx); + } + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + if (k === "Component" && typeof v === "function") continue; + out[k] = walkValue(v, ctx); + } + return out; + } + return value; +} diff --git a/utils/mod.ts b/utils/mod.ts index c46c6627d..b30a9079a 100644 --- a/utils/mod.ts +++ b/utils/mod.ts @@ -12,7 +12,7 @@ export { adminUrlFor, isAdmin, resolvable } from "./admin.ts"; */ export { readFromStream } from "./http.ts"; export { metabasePreview } from "./metabase.tsx"; -export { tryOrDefault } from "./object.ts"; +export { deepOmit, tryOrDefault } from "./object.ts"; export type { DotNestedKeys } from "./object.ts"; export { createServerTimings } from "./timings.ts"; export type { Device } from "./userAgent.ts"; diff --git a/utils/object.ts b/utils/object.ts index 7d3b9db6f..dc71c7747 100644 --- a/utils/object.ts +++ b/utils/object.ts @@ -146,3 +146,59 @@ export const tryOrDefault = (fn: () => R, defaultValue: R): R => { return defaultValue; } }; + +const omitAtPath = (obj: unknown, parts: string[]): unknown => { + if (!obj || typeof obj !== "object" || parts.length === 0) return obj; + + const [key, ...rest] = parts; + + // `*` fans the remaining path out over every array element / record value. + if (key === "*") { + if (Array.isArray(obj)) { + return obj.map((v) => omitAtPath(v, rest)); + } + return Object.fromEntries( + Object.entries(obj as Record).map(([k, v]) => [ + k, + omitAtPath(v, rest), + ]), + ); + } + + // Preserve array shape: apply the same path to each element. + if (Array.isArray(obj)) { + return obj.map((v) => omitAtPath(v, parts)); + } + + const current = obj as Record; + + if (rest.length === 0) { + const copy = { ...current }; + delete copy[key]; + return copy; + } + + // Omit is a no-op when the path is absent — never create `undefined` branches. + if (!(key in current)) return current; + + return { + ...current, + [key]: omitAtPath(current[key], rest), + }; +}; + +/** + * Removes properties from `obj` by path, immutably. + * + * Supports top-level keys (`"seoProps"`), nested dot-notation paths + * (`"page.seo"`), and the `*` wildcard over record/array values + * (`"page.productsMap.*.internalFlag"`). Prefer typed rest-destructuring + * for top-level keys; reach for deepOmit on deep paths and dynamic keys. + */ +export const deepOmit = (obj: T, ...paths: string[]): T => { + let result: unknown = obj; + for (const path of paths) { + result = omitAtPath(result, path.split(".")); + } + return result as T; +};