-
Notifications
You must be signed in to change notification settings - Fork 8
Configurable cache headers for the static plugin (maxAge, immutable, cacheControl) #1748
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
Merged
+276
−1
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
16a3523
Configurable cache headers for the static plugin (maxAge, immutable, …
c289c6a
Fail loudly on malformed maxAge duration string
6be5cd4
Format fixture css with prettier 3.9
de7f78a
Address bot review: validate maxAge type before conversion, document …
117ad1a
Per-file cacheOverrides map for the static plugin
kriszyp 019254f
Add year (y/Y) suffix to convertToMS
kriszyp File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
123 changes: 123 additions & 0 deletions
123
integrationTests/components/static-cache-headers.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| /** | ||
| * Static plugin cache-header options (`maxAge`, `immutable`, `cacheControl`). | ||
| * | ||
| * The plugin reads these options live from the component config on every request, so a single | ||
| * instance covers all variants: assert the default, then rewrite config.yaml via | ||
| * set_component_file and poll until the next request reflects the new policy. | ||
| * | ||
| * Run: npm run test:integration -- "integrationTests/components/static-cache-headers.test.ts" | ||
| */ | ||
| import { suite, test, before, after } from 'node:test'; | ||
| import { strictEqual, ok } from 'node:assert'; | ||
| import { resolve } from 'node:path'; | ||
| import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing'; | ||
| // @ts-expect-error utils/client.mjs has no type declarations; runtime resolves fine | ||
| import { createApiClient } from '../apiTests/utils/client.mjs'; | ||
|
|
||
| const FIXTURE_PATH = resolve(import.meta.dirname, '../fixtures/static-cache-headers'); | ||
| const PROJECT = 'static-cache-headers'; | ||
|
|
||
| suite('static plugin cache-header options', (ctx: ContextWithHarper) => { | ||
| let client: any; | ||
|
|
||
| before(async () => { | ||
| await setupHarperWithFixture(ctx, FIXTURE_PATH); | ||
| client = createApiClient(ctx.harper); | ||
| }); | ||
|
|
||
| after(async () => { | ||
| await teardownHarper(ctx); | ||
| }); | ||
|
|
||
| async function getPath(path: string): Promise<Response> { | ||
| const res = await fetch(new URL(path, ctx.harper.httpURL)); | ||
| strictEqual(res.status, 200); | ||
| await res.text(); // drain | ||
| return res; | ||
| } | ||
|
|
||
| async function getCss(): Promise<Response> { | ||
| return getPath('/test.css'); | ||
| } | ||
|
|
||
| async function setStaticConfig(yaml: string): Promise<void> { | ||
| await client.req().send({ operation: 'set_component_file', project: PROJECT, file: 'config.yaml', payload: yaml }); | ||
| } | ||
|
|
||
| /** | ||
| * Set the config and poll until the served Cache-Control matches. The config is re-sent | ||
| * periodically during the poll: a config write landing milliseconds after the previous one can | ||
| * lose its chokidar change event (watcher re-establishment race — see #1747), and the reload | ||
| * plumbing is not what this suite tests; the header options are. | ||
| */ | ||
| async function applyAndWaitForCacheControl( | ||
| yaml: string, | ||
| expected: string | null, | ||
| timeoutMs = 20_000 | ||
| ): Promise<Response> { | ||
| await setStaticConfig(yaml); | ||
| const deadline = Date.now() + timeoutMs; | ||
| let last: string | null = null; | ||
| let sinceResend = 0; | ||
| while (Date.now() < deadline) { | ||
| const res = await getCss(); | ||
| last = res.headers.get('cache-control'); | ||
| if (last === expected) return res; | ||
| if (++sinceResend >= 10) { | ||
| sinceResend = 0; | ||
| await setStaticConfig(yaml); | ||
| } | ||
| await new Promise((r) => setTimeout(r, 200)); | ||
| } | ||
| throw new Error(`Cache-Control never became ${JSON.stringify(expected)}; last seen: ${JSON.stringify(last)}`); | ||
| } | ||
|
|
||
| test('default: public, max-age=0 with ETag/Last-Modified', async () => { | ||
| const res = await getCss(); | ||
| strictEqual(res.headers.get('cache-control'), 'public, max-age=0'); | ||
| ok(res.headers.get('etag'), 'should emit an ETag'); | ||
| ok(res.headers.get('last-modified'), 'should emit Last-Modified'); | ||
| }); | ||
|
|
||
| test('maxAge in seconds', async () => { | ||
| await applyAndWaitForCacheControl("static:\n files: 'web/**'\n maxAge: 300\n", 'public, max-age=300'); | ||
| }); | ||
|
|
||
| test('maxAge as duration string + immutable', async () => { | ||
| await applyAndWaitForCacheControl( | ||
| "static:\n files: 'web/**'\n maxAge: 1d\n immutable: true\n", | ||
| 'public, max-age=86400, immutable' | ||
| ); | ||
| }); | ||
|
|
||
| test('cacheControl string overrides maxAge/immutable', async () => { | ||
| await applyAndWaitForCacheControl( | ||
| "static:\n files: 'web/**'\n maxAge: 300\n cacheControl: 'public, max-age=60, s-maxage=3600'\n", | ||
| 'public, max-age=60, s-maxage=3600' | ||
| ); | ||
| }); | ||
|
|
||
| test('cacheControl: false suppresses the header', async () => { | ||
| await applyAndWaitForCacheControl("static:\n files: 'web/**'\n cacheControl: false\n", null); | ||
| }); | ||
|
|
||
| test('cacheOverrides: per-file policy layered over the top-level defaults', async () => { | ||
| // Long-lived immutable default (the hashed-asset case); index.html gets its own short, | ||
| // revalidating window — Dawson's motivating example. | ||
| const yaml = | ||
| 'static:\n' + | ||
| " files: 'web/**'\n" + | ||
| ' maxAge: 1y\n' + | ||
| ' immutable: true\n' + | ||
| ' cacheOverrides:\n' + | ||
| " 'index.html': { cacheControl: 'public, max-age=0, stale-while-revalidate=60' }\n" + | ||
| ' "*.css": { maxAge: 60 }\n'; | ||
| // Poll on the css file until the override lands (confirms the config reload applied). The | ||
| // '*.css' override sets only maxAge, so immutable is inherited from the top level — partial merge. | ||
| await applyAndWaitForCacheControl(yaml, 'public, max-age=60, immutable'); | ||
| // index.html, matched by basename on the directory-index (`/`) serve, gets its full-string | ||
| // override, which takes precedence over the inherited maxAge/immutable. | ||
| const index = await getPath('/'); | ||
| strictEqual(index.headers.get('cache-control'), 'public, max-age=0, stale-while-revalidate=60'); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| static: | ||
| files: 'web/**' |
9 changes: 9 additions & 0 deletions
9
integrationTests/fixtures/static-cache-headers/web/index.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| <!doctype html> | ||
| <html> | ||
| <head> | ||
| <title>static cache headers fixture</title> | ||
| </head> | ||
| <body> | ||
| index | ||
| </body> | ||
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| body { | ||
| color: teal; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
1yis not a recognised suffix inconvertToMS— it silently producesmax-age=1.convertToMS(utility/common_utils.ts) handles'd'/'D','h'/'H','m','M'(month) — but has no'y'case. When YAML parsesmaxAge: 1yit delivers the string"1y".parseFloat("1y")is1, the switch falls through without a multiplier, andconvertToMSreturns1 × 1000 = 1000 ms.sendthen emitsCache-Control: public, max-age=1— one second, not one year.Because
parseFloat("1y")is a finite number (notNaN), the NaN guard below (Number.isNaN(maxAgeMs)) doesn't catch it. No error is raised; the setting silently misbehaves.The same value appears in the
cacheOverridesintegration test (case 6), but every file in that fixture has an override that takes precedence over the top-levelmaxAge, so the test passes without ever exercising the top-level1yvalue.Suggested fix: replace
1yin this example (and the test YAML atintegrationTests/components/static-cache-headers.test.ts:108) with a supported format such as365dor31536000. Alternatively, add a'y'case toconvertToMS.