-
Notifications
You must be signed in to change notification settings - Fork 52
Add page caching support and related headers #992
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
base: main
Are you sure you want to change the base?
Changes from all commits
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 |
|---|---|---|
|
|
@@ -78,3 +78,6 @@ server/ | |
| deps.json | ||
|
|
||
| deno.lock | ||
|
|
||
| .cursorindexingignore | ||
| .specstory/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| import { assert, assertEquals } from "@std/assert"; | ||
| import matcherBlock, { type MatcherModule } from "./matcher.ts"; | ||
| import { | ||
| DECO_PAGE_CACHE_ALLOW_HEADER, | ||
| DECO_PAGE_CACHE_CONTROL_HEADER, | ||
| } from "../utils/http.ts"; | ||
|
|
||
| // Minimal HttpContext stub with just what matcher.adapt needs | ||
| const makeHttpCtx = (resolverPath: string, headers: Headers) => | ||
| ({ | ||
| resolveChain: [{ type: "resolvable", value: resolverPath }], | ||
| context: { | ||
| state: { | ||
| response: { headers }, | ||
| flags: [] as Array< | ||
| { name: string; value: boolean; isSegment: boolean } | ||
| >, | ||
| }, | ||
| }, | ||
| request: new Request("http://local/"), | ||
| }) as any; | ||
|
|
||
| // A matcher module that always returns true (so we can see when page caching forces false) | ||
| const TRUE_MATCHER: MatcherModule = { | ||
| default: () => true, | ||
| }; | ||
|
|
||
| Deno.test("page cache ON: allows device matcher; blocks others by default", async () => { | ||
| const headers = new Headers(); | ||
| headers.set( | ||
| DECO_PAGE_CACHE_CONTROL_HEADER, | ||
| "public, s-maxage=60, max-age=10", | ||
| ); | ||
|
|
||
| // Allowed: device | ||
| { | ||
| const ctx = makeHttpCtx("/site/matchers/device.tsx", headers); | ||
| const adapted = matcherBlock.adapt!(TRUE_MATCHER, "site/matchers/device.tsx"); | ||
| const fn = await (adapted as any)({}, ctx); | ||
| const result = fn({ device: {} as any, siteId: 1, request: ctx.request }); | ||
| assert(result, "device matcher should be allowed when page-cache is ON"); | ||
| // flag recorded and true | ||
| const flag = ctx.context.state.flags[0]; | ||
| assertEquals(flag?.name, "/site/matchers/device.tsx"); | ||
| assertEquals(flag?.value, true); | ||
| } | ||
|
|
||
| // Blocked: any non device/time matcher | ||
| { | ||
| const ctx = makeHttpCtx("/site/matchers/url.tsx", headers); | ||
| const adapted = matcherBlock.adapt!(TRUE_MATCHER, "site/matchers/url.tsx"); | ||
| const fn = await (adapted as any)({}, ctx); | ||
| const result = fn({ device: {} as any, siteId: 1, request: ctx.request }); | ||
| assertEquals( | ||
| result, | ||
| false, | ||
| "non device/time matchers must be disabled when page-cache is ON", | ||
| ); | ||
| const flag = ctx.context.state.flags[0]; | ||
| assertEquals(flag?.name, "/site/matchers/url.tsx"); | ||
| assertEquals(flag?.value, false); | ||
| } | ||
| }); | ||
|
|
||
| Deno.test("page cache ON: allows time matchers (date/cron)", async () => { | ||
| const headers = new Headers(); | ||
| headers.set( | ||
| DECO_PAGE_CACHE_CONTROL_HEADER, | ||
| "public, s-maxage=60, max-age=10", | ||
| ); | ||
|
|
||
| const dateCtx = makeHttpCtx("/site/matchers/date.ts", headers); | ||
| const dateAdapted = matcherBlock.adapt!(TRUE_MATCHER, "site/matchers/date.ts"); | ||
| const dateFn = await (dateAdapted as any)({}, dateCtx); | ||
| assert( | ||
| dateFn({ device: {} as any, siteId: 1, request: dateCtx.request }), | ||
| "date matcher should be allowed", | ||
| ); | ||
| assertEquals(dateCtx.context.state.flags[0]?.value, true); | ||
|
|
||
| const cronCtx = makeHttpCtx("/site/matchers/cron.ts", headers); | ||
| const cronAdapted = matcherBlock.adapt!(TRUE_MATCHER, "site/matchers/cron.ts"); | ||
| const cronFn = await (cronAdapted as any)({}, cronCtx); | ||
| assert( | ||
| cronFn({ device: {} as any, siteId: 1, request: cronCtx.request }), | ||
| "cron matcher should be allowed", | ||
| ); | ||
| assertEquals(cronCtx.context.state.flags[0]?.value, true); | ||
| }); | ||
|
|
||
| Deno.test("page cache ON: honor allow-list header", async () => { | ||
| const headers = new Headers(); | ||
| headers.set( | ||
| DECO_PAGE_CACHE_CONTROL_HEADER, | ||
| "public, s-maxage=60, max-age=10", | ||
| ); | ||
| headers.set(DECO_PAGE_CACHE_ALLOW_HEADER, "device"); // time not allowed now | ||
|
|
||
| const ctx = makeHttpCtx("/site/matchers/date.ts", headers); | ||
| const adapted = matcherBlock.adapt!(TRUE_MATCHER, "site/matchers/date.ts"); | ||
| const fn = await (adapted as any)({}, ctx); | ||
| const result = fn({ device: {} as any, siteId: 1, request: ctx.request }); | ||
| assertEquals( | ||
| result, | ||
| false, | ||
| "time matcher should be disabled when 'device' is the only allowed group", | ||
| ); | ||
| }); | ||
|
|
||
| Deno.test("page cache OFF: evaluate matcher normally", async () => { | ||
| const headers = new Headers(); // no page cache header | ||
| const ctx = makeHttpCtx("/site/matchers/url.tsx", headers); | ||
| const adapted = matcherBlock.adapt!(TRUE_MATCHER, "site/matchers/url.tsx"); | ||
| const fn = await (adapted as any)({}, ctx); | ||
| const result = fn({ device: {} as any, siteId: 1, request: ctx.request }); | ||
| assert( | ||
| result, | ||
| "when page-cache is OFF, non device/time matchers are evaluated normally", | ||
| ); | ||
| }); | ||
|
|
||
| // Sticky-session: ensure that when page cache is ON and a matcher is not allowed, | ||
| // we do not set sticky cookies. | ||
| Deno.test("page cache ON: sticky-session matcher does not set cookie when disabled", async () => { | ||
| const headers = new Headers(); | ||
| headers.set( | ||
| DECO_PAGE_CACHE_CONTROL_HEADER, | ||
| "public, s-maxage=60, max-age=10", | ||
| ); | ||
| const STICKY_TRUE_MATCHER: MatcherModule = { | ||
| sticky: "session", | ||
| default: () => true, | ||
| sessionKey: () => "k", | ||
| } as any; | ||
| const ctx = makeHttpCtx("/site/matchers/url.tsx", headers); | ||
| const adapted = matcherBlock.adapt!(STICKY_TRUE_MATCHER, "site/matchers/url.tsx"); | ||
| const fn = await (adapted as any)({}, ctx); | ||
| const result = fn({ device: {} as any, siteId: 1, request: ctx.request }); | ||
| assertEquals(result, false); | ||
| // should not set cookie when disabled by page caching | ||
| assertEquals(headers.has("set-cookie"), false); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import { | ||
| DEFAULT_CACHE_CONTROL, | ||
| formatCacheControl, | ||
| normalizeCacheControlHeader, | ||
| parseCacheControl, | ||
| } from "./http.ts"; | ||
| import { assertEquals, assertStringIncludes } from "@std/assert"; | ||
|
|
||
| Deno.test("normalizeCacheControlHeader: true uses DEFAULT_CACHE_CONTROL", () => { | ||
| const header = normalizeCacheControlHeader(true)!; | ||
| const expected = formatCacheControl(DEFAULT_CACHE_CONTROL); | ||
| assertEquals(header, expected); | ||
| }); | ||
|
|
||
| Deno.test("normalizeCacheControlHeader: valid string is preserved (normalized)", () => { | ||
| const input = | ||
| "public, s-maxage=120, max-age=10, stale-while-revalidate=3600, stale-if-error=86400"; | ||
| const header = normalizeCacheControlHeader(input)!; | ||
| // Order is not guaranteed; verify presence of key segments | ||
| assertStringIncludes(header, "public"); | ||
| assertStringIncludes(header, "s-maxage=120"); | ||
| assertStringIncludes(header, "max-age=10"); | ||
| // Round-trip parsing should preserve numeric values | ||
| const parsed = parseCacheControl(new Headers({ "cache-control": header })); | ||
| assertEquals(parsed["public"], true); | ||
| assertEquals(parsed["s-maxage"], 120); | ||
| assertEquals(parsed["max-age"], 10); | ||
| }); | ||
|
|
||
| Deno.test("normalizeCacheControlHeader: invalid string falls back to default", () => { | ||
| const header = normalizeCacheControlHeader("totally-invalid-directive=foo")!; | ||
| const expected = formatCacheControl(DEFAULT_CACHE_CONTROL); | ||
| assertEquals(header, expected); | ||
| }); | ||
|
|
||
| Deno.test("normalizeCacheControlHeader: falsy => undefined (disabled)", () => { | ||
| const h1 = normalizeCacheControlHeader(undefined); | ||
| const h2 = normalizeCacheControlHeader(false); | ||
| assertEquals(h1, undefined); | ||
| assertEquals(h2, undefined); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -143,6 +143,41 @@ export const defaultHeaders = { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ["x-powered-by"]: `deco@${denoJSON.version}`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Internal header used to signal that the current page should emit Cache-Control. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * The value must be a valid Cache-Control string; when present, middleware will apply it if safe. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const DECO_PAGE_CACHE_CONTROL_HEADER = "x-deco-page-cache-control"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Internal header used to list which matcher groups are allowed to vary when page cache-control is enabled. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Example value: "device,time" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const DECO_PAGE_CACHE_ALLOW_HEADER = "x-deco-page-cache-allow"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Normalize a cache-control configuration into a valid header string. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * - true => DEFAULT_CACHE_CONTROL | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * - string => parsed/validated and re-formatted; on invalid, falls back to DEFAULT_CACHE_CONTROL | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * - falsy/undefined => undefined (disabled) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const normalizeCacheControlHeader = ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| value?: boolean | string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): string | undefined => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!value) return undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (value === true) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return formatCacheControl(DEFAULT_CACHE_CONTROL); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const h = new Headers({ "cache-control": value }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const parsed = parseCacheControl(h); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // If nothing parsed, treat as invalid | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const normalized = formatCacheControl(parsed); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return normalized || formatCacheControl(DEFAULT_CACHE_CONTROL); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return formatCacheControl(DEFAULT_CACHE_CONTROL); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+163
to
+179
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. Fallback when cache-control numbers are invalid. Line 176 ends up returning strings like Apply this diff: try {
const h = new Headers({ "cache-control": value });
const parsed = parseCacheControl(h);
+ const hasInvalidNumbers = Object.values(parsed).some((entry) =>
+ typeof entry === "number" && !Number.isFinite(entry)
+ );
+ if (hasInvalidNumbers) {
+ return formatCacheControl(DEFAULT_CACHE_CONTROL);
+ }
// If nothing parsed, treat as invalid
const normalized = formatCacheControl(parsed);
return normalized || formatCacheControl(DEFAULT_CACHE_CONTROL);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function setCSPHeaders( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| request: Request, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| response: Response, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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.
🧩 Analysis chain
🏁 Script executed:
rg -n 'DECO_PAGE_CACHE_ALLOW_HEADER' --type ts -A2 -B2Repository: deco-cx/deco
Length of output: 1793
🏁 Script executed:
rg -n 'set.*DECO_PAGE_CACHE_ALLOW_HEADER|\.set\([^,]*x-deco-page-cache-allow' --type ts -A1 -B1Repository: deco-cx/deco
Length of output: 251
🏁 Script executed:
Repository: deco-cx/deco
Length of output: 2453
Consider trimming individual values when parsing the allow-list header.
While the documented format is
"device,time"(without spaces), the current.includes()checks are susceptible to malformed headers. For example, if a header is set to"device, time"(with space after comma),allow.includes("time")would fail. Use.split(",").map(s => s.trim())to make the parsing more robust.🤖 Prompt for AI Agents