From a0331d34b1855de5d3476262facb39d8d122d571 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 9 Jul 2026 18:55:56 -0600 Subject: [PATCH 1/2] Document cacheControl directive, identity-floor caching, and static plugin cache options Covers two harper core PRs (#1746 and #1748): - schema.md: add `cacheControl` to the `@table` argument table and a dedicated `#### cacheControl` section explaining anonymous-only emission, the identity floor on authenticated responses, the explicit-opt-in requirement, and the `static cacheControl` JS class-property equivalent. - rest/headers.md: add a Cache-Control section documenting the three- tier policy (anonymous declared, authenticated identity floor, CORS Vary:Origin). - http/configuration.md: one-sentence note that CORS-enabled responses now emit `Vary: Origin` so shared caches key correctly by origin. - static-files/overview.md: add a "Cache Headers" subsection with `maxAge`, `immutable`, and `cacheControl` option docs, including the notFound fallback keeping max-age=0 and the public-by-construction note. Co-Authored-By: Claude Opus 4.8 (1M context) --- reference/database/schema.md | 48 +++++++++++++++++++++++++----- reference/http/configuration.md | 2 ++ reference/rest/headers.md | 33 ++++++++++++++++++++ reference/static-files/overview.md | 24 +++++++++++++++ 4 files changed, 99 insertions(+), 8 deletions(-) diff --git a/reference/database/schema.md b/reference/database/schema.md index 5b342f06..d12e32e1 100644 --- a/reference/database/schema.md +++ b/reference/database/schema.md @@ -73,14 +73,15 @@ type MyTable @table { Optional arguments: -| Argument | Type | Default | Description | -| -------------- | --------- | ----------------------------- | --------------------------------------------------------------------------- | -| `table` | `String` | type name | Override the table name | -| `database` | `String` | `"data"` | Database to place the table in | -| `expiration` | `Int` | — | Seconds until a record goes stale (useful for caching tables) | -| `eviction` | `Int` | `0` | Additional seconds after `expiration` before a record is physically removed | -| `scanInterval` | `Int` | `(expiration + eviction) / 4` | Seconds between eviction scans | -| `replicate` | `Boolean` | true | Enable replication of this table | +| Argument | Type | Default | Description | +| -------------- | --------- | ----------------------------- | ------------------------------------------------------------------------------------------- | +| `table` | `String` | type name | Override the table name | +| `database` | `String` | `"data"` | Database to place the table in | +| `expiration` | `Int` | — | Seconds until a record goes stale (useful for caching tables) | +| `eviction` | `Int` | `0` | Additional seconds after `expiration` before a record is physically removed | +| `scanInterval` | `Int` | `(expiration + eviction) / 4` | Seconds between eviction scans | +| `replicate` | `Boolean` | true | Enable replication of this table | +| `cacheControl` | `String` | — | `Cache-Control` header value emitted on anonymous GET/HEAD 200/304 responses for this table | **`expiration`, `eviction`, and `scanInterval`** @@ -162,6 +163,37 @@ type Event @table(database: "analytics", expiration: 86400) { **Replication:** Replication is enabled by default for all tables. Note that if you disable replication on a table and re-enable it later, it will not catch-up on previous writes during when the replication was disabled. +#### `cacheControl` + + + +The `cacheControl` argument sets a `Cache-Control` header value emitted on anonymous (unauthenticated) GET/HEAD `200`/`304` responses for this table. It is designed for tables whose content is public and safe for shared caches (CDNs, reverse proxies) to store. + +```graphql +type Product @table(cacheControl: "public, max-age=60") @export { + id: Long @primaryKey + name: String + price: Float +} +``` + +Key semantics: + +- **Anonymous reads only.** The header is emitted only when the request carries no authenticated principal. Authenticated responses instead receive an identity floor of `Cache-Control: private, no-cache` (plus `Vary: Authorization`/`Cookie`) — the table declaration is not inherited by authenticated reads. +- **Explicit opt-in required.** Anonymous readability alone does not cause Harper to emit shared-cache headers, because a table gated on request attributes (IP, headers, etc.) could leak across a URL-keyed cache. The `cacheControl` declaration is the explicit statement that the content is safe to cache publicly. +- **Never on 401 responses.** A rejected request always gets `Cache-Control: private, no-cache` regardless of any declaration. +- **Visible in `describe_table`.** The value is stored with the table schema and surfaces in the Operations API's `describe_table` output. + +The declaration can equivalently be made as a `static cacheControl` property on an exported JavaScript resource class: + +```javascript +export class Product extends tables.Product { + static cacheControl = 'public, max-age=60'; +} +``` + +See [REST Headers / Cache-Control](../rest/headers.md#cache-control) for the full caching behavior and how `Cache-Control` interacts with authentication headers. + ### `@export` Exposes the table as an externally accessible resource endpoint, available via REST, MQTT, and other interfaces. diff --git a/reference/http/configuration.md b/reference/http/configuration.md index bb77b328..ab9d85a5 100644 --- a/reference/http/configuration.md +++ b/reference/http/configuration.md @@ -159,6 +159,8 @@ Default: `"Accept, Content-Type, Authorization"` Comma-separated list of headers allowed in the [`Access-Control-Allow-Headers`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers) response header for OPTIONS (preflight) requests. + When CORS is enabled, Harper emits `Vary: Origin` on all responses (including no-origin requests whose `Access-Control-Allow-Origin`-less variant is also origin-dependent), so that shared caches key responses correctly by origin. + ## Session Affinity ### `http.sessionAffinity` diff --git a/reference/rest/headers.md b/reference/rest/headers.md index 818398eb..7bef6be5 100644 --- a/reference/rest/headers.md +++ b/reference/rest/headers.md @@ -20,6 +20,39 @@ These headers are included in all Harper REST API responses: | `etag` | `"abc123"` | Encoded version/last-modification time of the returned record. Used for conditional requests. | | `location` | `/MyTable/new-id` | Returned on `POST` responses. Contains the path to the newly created record. | +## Cache-Control + + + +Harper applies a tiered Cache-Control policy to REST responses. + +### Anonymous reads + +For tables (or JS resource classes) that declare a `cacheControl` value, Harper emits that value verbatim on anonymous (unauthenticated) GET/HEAD `200`/`304` responses: + +``` +Cache-Control: public, max-age=60 +``` + +See [`@table(cacheControl: "...")`](../database/schema.md#cachecontrol) for how to declare this value in your schema. + +### Authenticated reads (identity floor) + +Any response where a principal was resolved — or where credentials were rejected (including `401` and the login-redirect `302`) — receives an identity floor regardless of any table declaration: + +``` +Cache-Control: private, no-cache +Vary: Authorization +``` + +When cookie-based sessions are in use, `Cookie` is appended to `Vary`. + +An app may explicitly set a `public` or `s-maxage` directive in a resource handler to opt an authenticated response into shared caching (per RFC 9111). The opt-in is trusted, except on `401` responses: a rejected request always receives `private, no-cache`. + +### CORS responses + +When CORS is enabled, responses that reflect an `Origin` header also emit `Vary: Origin` so that shared caches key correctly by origin. See [CORS configuration](../http/configuration.md#cors). + ## Request Headers ### Content-Type diff --git a/reference/static-files/overview.md b/reference/static-files/overview.md index ee255067..28940b9a 100644 --- a/reference/static-files/overview.md +++ b/reference/static-files/overview.md @@ -74,6 +74,30 @@ In addition to the standard `files`, `urlPath`, and `timeout` options, `static` - **`notFound`** - `string | { file: string; statusCode: number }` - _optional_ - A custom file (or file + status code) to return when a path is not found. Useful for serving a custom 404 page or for SPAs that use client-side routing. +### Cache Headers + + + +Static files are served before authentication runs, so they are public by construction. Use the following options to control the `Cache-Control` header Harper emits for served files: + +- **`maxAge`** - `number` (seconds) or `string` (duration) - _optional_ - Freshness lifetime for served files. A number is treated as seconds; a string like `'5m'` or `'1d'` is parsed as a duration. Emits `Cache-Control: public, max-age=`. Defaults to `0` (revalidate on every request via ETag/Last-Modified). Malformed values throw at startup rather than silently degrading. + +- **`immutable`** - `boolean` - _optional_ - When `true`, appends the `immutable` directive to the `Cache-Control` header. Use this for content-hashed assets whose URL changes when the content changes, so browsers and CDNs never revalidate them. Defaults to `false`. + +- **`cacheControl`** - `string | false` - _optional_ - Full `Cache-Control` override string. Takes precedence over `maxAge` and `immutable` when set. Set to `false` to suppress the `Cache-Control` header entirely. + +The `notFound` fallback always uses `max-age=0` regardless of `maxAge`, which is correct for SPA index fallbacks — a `200 index.html` response should revalidate so updated builds are picked up promptly. + +Example: serve a Next.js-style `_next/static/` directory with long-lived caching for hashed assets: + +```yaml +static: + files: 'web/_next/static/**' + urlPath: '_next/static' + maxAge: '1y' + immutable: true +``` + ## Auto-Updates From 3b5d4b2107ed1377e729944b926f347c7ce6946b Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sat, 11 Jul 2026 06:05:27 -0600 Subject: [PATCH 2/2] Document static plugin cacheOverrides and duration units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add the cacheOverrides per-file override map (glob -> partial cache options, first-match-wins, basename+URL matching) as a follow-up to the top-level maxAge/immutable/cacheControl options. - Note the full set of maxAge duration suffixes incl. the new `y` (year) and the case-sensitive M(month)/m(minute) distinction. - Add CDN guidance for stale-while-revalidate (CloudFront/Cloudflare honor it, Azure CDN ignores it — safe to set either way). Co-Authored-By: Claude Opus 4.8 (1M context) --- reference/static-files/overview.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/reference/static-files/overview.md b/reference/static-files/overview.md index 28940b9a..d1ccd553 100644 --- a/reference/static-files/overview.md +++ b/reference/static-files/overview.md @@ -80,24 +80,34 @@ In addition to the standard `files`, `urlPath`, and `timeout` options, `static` Static files are served before authentication runs, so they are public by construction. Use the following options to control the `Cache-Control` header Harper emits for served files: -- **`maxAge`** - `number` (seconds) or `string` (duration) - _optional_ - Freshness lifetime for served files. A number is treated as seconds; a string like `'5m'` or `'1d'` is parsed as a duration. Emits `Cache-Control: public, max-age=`. Defaults to `0` (revalidate on every request via ETag/Last-Modified). Malformed values throw at startup rather than silently degrading. +- **`maxAge`** - `number` (seconds) or `string` (duration) - _optional_ - Freshness lifetime for served files. A number is treated as seconds; a string like `'5m'` or `'1d'` is parsed as a duration. Emits `Cache-Control: public, max-age=`. Defaults to `0` (revalidate on every request via ETag/Last-Modified). Malformed values throw at startup rather than silently degrading. Duration suffixes are `y` (year), `M` (30-day month), `d` (day), `h` (hour), and `m` (minute) — note the case-sensitive distinction between `M` (month) and `m` (minute). - **`immutable`** - `boolean` - _optional_ - When `true`, appends the `immutable` directive to the `Cache-Control` header. Use this for content-hashed assets whose URL changes when the content changes, so browsers and CDNs never revalidate them. Defaults to `false`. - **`cacheControl`** - `string | false` - _optional_ - Full `Cache-Control` override string. Takes precedence over `maxAge` and `immutable` when set. Set to `false` to suppress the `Cache-Control` header entirely. +- **`cacheOverrides`** - `object` - _optional_ - A map of glob pattern → per-file cache options (any of `maxAge`, `immutable`, `cacheControl`), letting specific files opt out of the top-level defaults. The typical case is long-lived `immutable` defaults for content-hashed assets while `index.html` gets a short window or `stale-while-revalidate`. Patterns use the same [`micromatch`](https://github.com/micromatch/micromatch) engine as `files`, and are matched against both the request URL path (relative to the mount) and the served file's basename — so `index.html` also targets the directory-index (`/`) response. Entries are tested in config order and the **first match wins**; each entry is a partial — options it sets replace the top-level default, options it omits are inherited, with the same `cacheControl`-over-`maxAge`/`immutable` precedence. + The `notFound` fallback always uses `max-age=0` regardless of `maxAge`, which is correct for SPA index fallbacks — a `200 index.html` response should revalidate so updated builds are picked up promptly. -Example: serve a Next.js-style `_next/static/` directory with long-lived caching for hashed assets: +Example: serve a build directory with long-lived immutable caching for hashed assets, while the entry `index.html` revalidates every request (optionally serving stale content while it does): ```yaml static: - files: 'web/_next/static/**' - urlPath: '_next/static' + files: 'web/**' maxAge: '1y' immutable: true + cacheOverrides: + # First match wins; index.html is matched by basename on the `/` serve. + 'index.html': + cacheControl: 'public, max-age=0, stale-while-revalidate=60' + '*.html': + maxAge: '5m' + immutable: false ``` +> `stale-while-revalidate` support varies by CDN — CloudFront and Cloudflare honor it, Azure CDN does not. Where it is unsupported the directive is simply ignored (the resource is treated as `max-age=0`), so it is safe to set. + ## Auto-Updates