diff --git a/blocks/utils.tsx b/blocks/utils.tsx index fa63c6806..a8148a9c2 100644 --- a/blocks/utils.tsx +++ b/blocks/utils.tsx @@ -81,6 +81,8 @@ export interface RequestState { }; bag: WeakMap; flags: Flag[]; + dirty?: boolean; + dirtyTraces?: string[]; } export type FnContext< diff --git a/runtime/middleware.ts b/runtime/middleware.ts index 0b7ff8bd1..7373c8933 100644 --- a/runtime/middleware.ts +++ b/runtime/middleware.ts @@ -49,9 +49,7 @@ export const proxyState = ( }; export type DecoMiddleware = - MiddlewareHandler< - DecoRouteState - >; + MiddlewareHandler>; export type DecoRouteState = { Variables: State; @@ -107,10 +105,11 @@ async (ctx, next) => { const DEBUG_COOKIE = "deco_debug"; const DEBUG_ENABLED = "enabled"; +const PAGE_CACHE_DRY_RUN = Deno.env.get("DECO_PAGE_CACHE_DRY_RUN") === "true"; export const DEBUG_QS = "__d"; -const addHours = function (date: Date, h: number) { - date.setTime(date.getTime() + (h * 60 * 60 * 1000)); +const addHours = (date: Date, h: number) => { + date.setTime(date.getTime() + h * 60 * 60 * 1000); return date; }; @@ -136,20 +135,19 @@ export const DEBUG = { ): { action: DebugAction; enabled: boolean; correlationId: string } => { const url = new URL(request.url); const debugFromCookies = getCookies(request.headers)[DEBUG_COOKIE]; - const debugFromQS = url.searchParams.has(DEBUG_QS) && DEBUG_ENABLED || + const debugFromQS = (url.searchParams.has(DEBUG_QS) && DEBUG_ENABLED) || url.searchParams.get(DEBUG_COOKIE); const hasDebugFromQS = debugFromQS !== null; const isLivePreview = url.pathname.includes("/live/previews/"); - const enabled = ((debugFromQS ?? debugFromCookies) === DEBUG_ENABLED) || + const enabled = (debugFromQS ?? debugFromCookies) === DEBUG_ENABLED || isLivePreview; - const correlationId = url.searchParams.get(DEBUG_QS) || - crypto.randomUUID(); + const correlationId = url.searchParams.get(DEBUG_QS) || crypto.randomUUID(); const liveContext = Context.active(); // querystring forces a setcookie using the querystring value return { action: hasDebugFromQS || isLivePreview - ? (enabled + ? enabled ? async (resp) => { DEBUG.enable(resp); resp.headers.set("x-correlation-id", correlationId); @@ -159,7 +157,7 @@ export const DEBUG = { ); resp.headers.set( "x-deco-revision", - `${await liveContext.release?.revision() ?? ""}`, + `${(await liveContext.release?.revision()) ?? ""}`, ); resp.headers.set( "x-isolate-started-at", @@ -171,7 +169,7 @@ export const DEBUG = { `${liveContext.instance.readyAt.toISOString()}`, ); } - : DEBUG.disable) + : DEBUG.disable : DEBUG.none, enabled, correlationId, @@ -221,19 +219,14 @@ export const middlewareFor = ( liveness, // 1 => statebuilder async (ctx, next) => { - const { enabled, action, correlationId } = DEBUG.fromRequest( - ctx.req.raw, - ); + const { enabled, action, correlationId } = DEBUG.fromRequest(ctx.req.raw); - //@ts-ignore: ctx.base dont exist in hono ctx + //@ts-expect-error: ctx.base dont exist in hono ctx ctx.base = ctx.var.global; - const state = await deco.prepareState( - ctx, - { - enabled, - correlationId, - }, - ); + const state = await deco.prepareState(ctx, { + enabled, + correlationId, + }); for (const [key, value] of Object.entries(state)) { ctx.set(key as keyof typeof state, value); } @@ -253,7 +246,7 @@ export const middlewareFor = ( if (ctx.req.raw.headers.get("upgrade") === "websocket") { return; } - ctx.res && await action(ctx.res); + ctx.res && (await action(ctx.res)); setLogger(null); }, // 2 => observability @@ -268,8 +261,7 @@ export const middlewareFor = ( "http.request.url": ctx.req.raw.url, "http.request.method": ctx.req.raw.method, "http.request.body.size": - ctx.req.raw.headers.get("content-length") ?? - undefined, + ctx.req.raw.headers.get("content-length") ?? undefined, "url.scheme": url.protocol, "server.address": url.host, "url.query": url.search, @@ -296,10 +288,7 @@ export const middlewareFor = ( span.setStatus({ code: isErr ? SpanStatusCode.ERROR : SpanStatusCode.OK, }); - span.setAttribute( - "http.response.status_code", - `${status}`, - ); + span.setAttribute("http.response.status_code", `${status}`); if (ctx?.var?.pathTemplate) { const route = `${ctx.req.raw.method} ${ctx?.var?.pathTemplate}`; span.updateName(route); @@ -310,9 +299,7 @@ export const middlewareFor = ( ctx.res?.status ?? 500, ); } else { - span.updateName( - `${ctx.req.raw.method} ${ctx.req.raw.url}`, - ); + span.updateName(`${ctx.req.raw.method} ${ctx.req.raw.url}`); } span.end(); if (!url.pathname.startsWith("/_frsh")) { @@ -333,10 +320,8 @@ export const middlewareFor = ( }, // 3 => main async (ctx, next) => { - if ( - ctx.req.raw.method === "HEAD" && isMonitoringRobots(ctx.req.raw) - ) { - return ctx.res = new Response(null, { status: 200 }); + if (ctx.req.raw.method === "HEAD" && isMonitoringRobots(ctx.req.raw)) { + return (ctx.res = new Response(null, { status: 200 })); } const url = new URL(ctx.req.raw.url); // TODO(mcandeia) check if ctx.url can be used here @@ -354,7 +339,7 @@ export const middlewareFor = ( // which means that sometimes it will fail as headers are immutable. // so I'm first setting it to undefined and just then set the entire response again ctx.res = undefined; - return ctx.res = initialResponse; + return (ctx.res = initialResponse); } const newHeaders = new Headers(initialResponse.headers); context.platform && newHeaders.set("x-deco-platform", context.platform); @@ -366,11 +351,9 @@ export const middlewareFor = ( url.pathname.startsWith("/_frsh/") || shouldAllowCorsForOptions ) { - Object.entries(allowCorsFor(ctx.req.raw)).map( - ([name, value]) => { - newHeaders.set(name, value); - }, - ); + Object.entries(allowCorsFor(ctx.req.raw)).map(([name, value]) => { + newHeaders.set(name, value); + }); } ctx.var.response.headers.forEach((value, key) => newHeaders.append(key, value) @@ -380,8 +363,7 @@ export const middlewareFor = ( reduceServerTimingsTo(printTimings(), SERVER_TIMING_MAX_LEN); printedTimings && newHeaders.set("Server-Timing", printedTimings); - const responseStatus = ctx.var.response.status ?? - initialResponse.status; + const responseStatus = ctx.var.response.status ?? initialResponse.status; if ( url.pathname.startsWith("/_frsh/") && @@ -405,10 +387,7 @@ export const middlewareFor = ( () => decodeCookie(currentCookies[DECO_SEGMENT]), "", ); - const segment = tryOrDefault( - () => JSON.parse(cookieSegment), - {}, - ); + const segment = tryOrDefault(() => JSON.parse(cookieSegment), {}); const active = new Set(segment.active || []); const inactiveDrawn = new Set(segment.inactiveDrawn || []); @@ -432,20 +411,37 @@ export const middlewareFor = ( if (hasFlags && cookieSegment !== value) { const date = new Date(); - date.setTime(date.getTime() + (30 * 24 * 60 * 60 * 1000)); // 1 month - setCookie(newHeaders, { - name: DECO_SEGMENT, - value, - path: "/", - expires: date, - sameSite: "Lax", - }, { encode: true }); + date.setTime(date.getTime() + 30 * 24 * 60 * 60 * 1000); // 1 month + setCookie( + newHeaders, + { + name: DECO_SEGMENT, + value, + path: "/", + expires: date, + sameSite: "Lax", + }, + { encode: true }, + ); } } // If response has set-cookie header, set cache-control to no-store if (getSetCookies(newHeaders).length > 0) { newHeaders.set("Cache-Control", "no-store, no-cache, must-revalidate"); + } else if (!ctx.var.dirty) { + if (PAGE_CACHE_DRY_RUN) { + console.warn(`[page-cache] cacheable: ${url.pathname}`); + } else { + newHeaders.set("Cache-Control", "public, max-age=120, s-maxage=120"); + } + } else if (PAGE_CACHE_DRY_RUN) { + console.warn( + `[page-cache] not cacheable (cookies accessed): ${url.pathname}`, + ); + for (const trace of ctx.var.dirtyTraces ?? []) { + console.warn(`[page-cache] trace:\n${trace}`); + } } // for some reason hono deletes content-type when response is not fresh. diff --git a/runtime/mod.ts b/runtime/mod.ts index 44784eec3..1cab9ef5a 100644 --- a/runtime/mod.ts +++ b/runtime/mod.ts @@ -73,17 +73,17 @@ export class Deco { public site: string, public ctx: DecoContext, public bindings?: Bindings, - ) { - } + ) {} static async init( opts?: DecoOptions, ): Promise> { const site = opts?.site ?? siteNameFromEnv() ?? randomSiteName(); - const decofile = opts?.decofile ?? await getProvider(); - const manifest = opts?.manifest ?? (await import( - toFileUrl(join(Deno.cwd(), "manifest.gen.ts")).href - ).then((mod) => mod.default)); + const decofile = opts?.decofile ?? (await getProvider()); + const manifest = opts?.manifest ?? + (await import(toFileUrl(join(Deno.cwd(), "manifest.gen.ts")).href).then( + (mod) => mod.default, + )); const decoContext = await Promise.resolve(manifest).then((m) => newContext( m, @@ -96,11 +96,7 @@ export class Deco { ) ); Context.setDefault(decoContext); - return new Deco( - site, - decoContext, - opts?.bindings, - ); + return new Deco(site, decoContext, opts?.bindings); } meta(opts?: GetMetaOpts): Promise { @@ -113,7 +109,7 @@ export class Deco { | { RENDER_FN?: ContextRenderer | undefined; GLOBALS?: unknown } | undefined, ) => Response | Promise { - return this._handler ??= handlerFor(this); + return (this._handler ??= handlerFor(this)); } get fetch(): (req: Request) => Response | Promise { return (req: Request) => this.handler(req); @@ -141,7 +137,8 @@ export class Deco { req, previewUrl, props, - ctx ?? await this.prepareState({ req: { raw: req, param: () => ({}) } }), + ctx ?? + (await this.prepareState({ req: { raw: req, param: () => ({}) } })), ); } @@ -154,7 +151,7 @@ export class Deco { req, opts, state ?? - await this.prepareState({ req: { raw: req, param: () => ({}) } }), + (await this.prepareState({ req: { raw: req, param: () => ({}) } })), ); } @@ -162,9 +159,7 @@ export class Deco { return invoke(...args); } - batchInvoke( - ...args: Parameters - ): Promise { + batchInvoke(...args: Parameters): Promise { return batchInvoke(...args); } @@ -173,12 +168,57 @@ export class Deco { req: { raw: Request; param: () => Record }; base?: unknown; }, - { enabled, correlationId }: { + { + enabled, + correlationId, + }: { enabled: boolean; correlationId?: string; } = { enabled: false }, ): Promise> { - const req = context.req.raw; + const _req = context.req.raw; + // Proxy the request headers to detect cookie access during resolution. + // When any resolver (loader, action, section) reads the "cookie" header, + // we flag state.dirty = true so the middleware knows not to cache. + const proxiedHeaders = new Proxy(_req.headers, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (typeof value === "function") { + return function (this: Headers, ...args: any[]) { + if ( + (prop === "get" || prop === "has") && + typeof args[0] === "string" && + args[0].toLowerCase() === "cookie" + ) { + state.dirty = true; + state.dirtyTraces!.push( + new Error( + `cookie header accessed via headers.${ + String( + prop, + ) + }("cookie")`, + ).stack ?? "", + ); + } + return value.apply(target, args); + }; + } + return value; + }, + }); + const req = new Proxy(_req, { + get(target, prop, receiver) { + if (prop === "headers") { + return proxiedHeaders; + } + const value = Reflect.get(target, prop, receiver); + if (typeof value === "function") { + return value.bind(target); + } + return value; + }, + }); const state = (context.base ?? {}) as State; state.deco = this; const t = createServerTimings(); @@ -191,12 +231,11 @@ export class Deco { timings: t, metrics: observe, tracer, - context: otelContext.active().setValue(REQUEST_CONTEXT_KEY, req) - .setValue( - STATE_CONTEXT_KEY, - state, - ), - logger: enabled ? console : { + context: otelContext + .active() + .setValue(REQUEST_CONTEXT_KEY, req) + .setValue(STATE_CONTEXT_KEY, state), + logger: enabled ? console : ({ assert: NOOP_CALL, clear: NOOP_CALL, count: NOOP_CALL, @@ -217,7 +256,7 @@ export class Deco { timeStamp: NOOP_CALL, trace: NOOP_CALL, warn: NOOP_CALL, - } as Console, + } as Console), }; const liveContext = this.ctx; @@ -233,12 +272,15 @@ export class Deco { state.bag = new WeakMap(); state.vary = vary(); state.flags = []; + state.dirty = false; + state.dirtyTraces = []; state.site = { id: this.ctx.siteId ?? 0, name: this.ctx.site, }; state.global = state; const { resolver } = await this.ctx.runtime!; + const ctxResolver = resolver .resolverFor( { @@ -253,7 +295,7 @@ export class Deco { return Reflect.get(target, prop, recv); }, }), - request, + request: req, }, { monitoring: state.monitoring, @@ -262,9 +304,13 @@ export class Deco { .bind(resolver); state.resolve = ctxResolver; - state.invoke = buildInvokeFunc(ctxResolver, {}, { - isInvoke: true, - }); + state.invoke = buildInvokeFunc( + ctxResolver, + {}, + { + isInvoke: true, + }, + ); return state; } diff --git a/runtime/routes/render.tsx b/runtime/routes/render.tsx index c9aa3b7cc..4255f0bfa 100644 --- a/runtime/routes/render.tsx +++ b/runtime/routes/render.tsx @@ -3,6 +3,8 @@ import { FieldResolver } from "../../engine/core/resolver.ts"; import { badRequest } from "../../engine/errors.ts"; +import { stableStringify } from "../../utils/json.ts"; +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"; @@ -73,6 +75,10 @@ const fromRequest = (req: Request): Options => { const DECO_RENDER_CACHE_CONTROL = Deno.env.get("DECO_RENDER_CACHE_CONTROL") || "public, max-age=60, s-maxage=60, stale-while-revalidate=3600, stale-if-error=86400"; +const AsyncRenderSF = singleFlight(); +const SHOULD_USE_ASYNC_RENDER_SINGLE_FLIGHT = + Deno.env.get("SHOULD_USE_ASYNC_RENDER_SINGLE_FLIGHT") === "true"; + export const handler = createHandler(async ( ctx, ) => { @@ -85,17 +91,29 @@ export const handler = createHandler(async ( if (isDebugRequest) { return Response.json({ debugData: state.vary.debug.build() }); } + const props = { + params: ctx.req.param(), + url: ctx.var.url, + data: { page }, + } satisfies PageParams; + + const renderFn = async () => + await render({ + page: { + Component: Render, + props, + }, + }); - const response = await render({ - page: { - Component: Render, - props: { - params: ctx.req.param(), - url: ctx.var.url, - data: { page }, - } satisfies PageParams, - }, - }); + // In case of async render, we use single flight to deduplicate the render calls. + // FEATURE_FLAG: SHOULD_USE_ASYNC_RENDER_SINGLE_FLIGHT + // Needs to measure if CPU time for rendering is higher than the time to deduplicate the render calls. + const response = SHOULD_USE_ASYNC_RENDER_SINGLE_FLIGHT + ? await AsyncRenderSF.do( + stableStringify({ ...props, url: ctx.var.url.href }), + renderFn, + ).then((r) => r.clone()) + : await renderFn(); // this is a hack to make sure we cache only sections that does not vary based on the loader content. // so we can calculate cacheBust per page but decide to cache sections individually based on vary. diff --git a/types.ts b/types.ts index 5db0fcb3e..089494b84 100644 --- a/types.ts +++ b/types.ts @@ -129,6 +129,8 @@ export type DecoState< & InvocationFunc; pathTemplate: string; routes?: Route[]; + dirty?: boolean; + dirtyTraces?: string[]; }; export type { JSONSchema7 } from "npm:@types/json-schema@7.0.11/index.d.ts";