-
Notifications
You must be signed in to change notification settings - Fork 55
feat(render): section-controlled JSON output via renderJson export #1208
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6c6b3a8
bbadf98
61d9563
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Cache-control logic is duplicated between the new JSON response path and the existing HTML response path. Extract into a shared helper to prevent drift. Prompt for AI agents |
||
| ? DECO_RENDER_CACHE_CONTROL | ||
| : "no-store, no-cache, must-revalidate", | ||
| ); | ||
| return jsonResponse; | ||
| } | ||
|
|
||
| const props = { | ||
| params: ctx.req.param(), | ||
| url: ctx.var.url, | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,198 @@ | ||||||||||||||||||||||||||||
| 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<string, unknown>; | ||||||||||||||||||||||||||||
| metadata?: { | ||||||||||||||||||||||||||||
| component?: string; | ||||||||||||||||||||||||||||
| resolveChain?: FieldResolver[]; | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export type SerializedSection = | ||||||||||||||||||||||||||||
| | { component: string; props: Record<string, unknown> } | ||||||||||||||||||||||||||||
| | { 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<typeof loader>` | ||||||||||||||||||||||||||||
| * (or the section's own Props) to keep it compile-checked. | ||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||
| * ```ts | ||||||||||||||||||||||||||||
| * export const renderJson = false; | ||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||
| * export const renderJson = ( | ||||||||||||||||||||||||||||
| * { internalOnly, ...rest }: SectionProps<typeof loader>, | ||||||||||||||||||||||||||||
| * ) => rest; | ||||||||||||||||||||||||||||
| * ``` | ||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||
| export type RenderJson = | ||||||||||||||||||||||||||||
| // deno-lint-ignore no-explicit-any | ||||||||||||||||||||||||||||
| | ((props: any) => unknown) | ||||||||||||||||||||||||||||
| | 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<string, SectionJsonModule>; | ||||||||||||||||||||||||||||
| })?.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, | ||||||||||||||||||||||||||||
| 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<string, unknown>, | ||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents |
||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| 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<string, unknown> = {}; | ||||||||||||||||||||||||||||
| for (const [k, v] of Object.entries(value)) { | ||||||||||||||||||||||||||||
| if (k === "Component" && typeof v === "function") continue; | ||||||||||||||||||||||||||||
| out[k] = walkValue(v, ctx); | ||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Object traversal keeps opted-out sections as Prompt for AI agents |
||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
Comment on lines
+191
to
+194
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Drop object properties for sections that opt out of JSON ( Line 193 always writes the walked value, so opted-out nested sections become Suggested fix const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
if (k === "Component" && typeof v === "function") continue;
- out[k] = walkValue(v, ctx);
+ if (isSectionShape(v)) {
+ const serialized = serializeResolvedSection(v, ctx);
+ if (serialized !== null) out[k] = serialized;
+ continue;
+ }
+ out[k] = walkValue(v, ctx);
}
return out;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| return out; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| return value; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -146,3 +146,56 @@ export const tryOrDefault = <R>(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<string, unknown>).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<string, unknown>; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if (rest.length === 0) { | ||||||||||||||||||||||||||||||||||||||
| const copy = { ...current }; | ||||||||||||||||||||||||||||||||||||||
| delete copy[key]; | ||||||||||||||||||||||||||||||||||||||
| return copy; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||
| ...current, | ||||||||||||||||||||||||||||||||||||||
| [key]: omitAtPath(current[key], rest), | ||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When Prompt for AI agents |
||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+181
to
+184
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not materialize missing branches during deep omit. At Line 181, nested omission always rebuilds Proposed fix if (rest.length === 0) {
const copy = { ...current };
delete copy[key];
return copy;
}
+
+ if (!(key in current)) {
+ return current;
+ }
return {
...current,
[key]: omitAtPath(current[key], rest),
};📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||
| * 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 = <T extends object>(obj: T, ...paths: string[]): T => { | ||||||||||||||||||||||||||||||||||||||
| let result: unknown = obj; | ||||||||||||||||||||||||||||||||||||||
| for (const path of paths) { | ||||||||||||||||||||||||||||||||||||||
| result = omitAtPath(result, path.split(".")); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| return result as T; | ||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Validate root shape before casting
pagetoResolvedSection.Line 110 force-casts
page, but the serializer uses non-null assertions onmetadata.component. If render output is not section-shaped, this can throw and return 500 instead of a controlled client error.Suggested fix
if (opts.searchParams.get("format") === "json") { + if ( + !page || typeof page !== "object" || + typeof (page as ResolvedSection).metadata?.component !== "string" + ) { + throw badRequest({ + code: "400", + message: "JSON format requires a resolved section root", + }); + } + const serializeCtx: SerializeContext = { href: opts.href, pathTemplate: opts.pathTemplate, renderSalt: opts.renderSalt, cb: opts.searchParams.get("__cb") ?? undefined, getSectionModule: await sectionModuleLookup(), };🤖 Prompt for AI Agents