@karibsen/eponyme 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -587,15 +587,44 @@ shorten. A visitor holding a copy keeps it until it expires, publication or not,
587
587
  why the default is deliberately small. Set it to `0` if a publication must be visible
588
588
  immediately to everyone; navigation then costs a round trip again.
589
589
 
590
- The CDN window can be long because it can be purged, and `eponyme:entry:published` is the
591
- place to do it:
590
+ ### Purging the CDN
591
+
592
+ The CDN window can be long because it can be purged. CDNs purge by **tag** rather than by
593
+ URL — one entry appears in several cached responses, its own and its collection's listing —
594
+ so every cacheable response carries `Vercel-Cache-Tag` and `Cache-Tag`:
595
+
596
+ | Response | Tags |
597
+ |---|---|
598
+ | `pages/homepage` | `eponyme`, `eponyme:pages/homepage` |
599
+ | `articles/my-article` | `eponyme`, `eponyme:articles/my-article`, `eponyme:articles` |
600
+ | The `articles` listing | `eponyme`, `eponyme:articles` |
601
+
602
+ `getEponymeCacheTags()` returns exactly those tags and is auto-imported into server code,
603
+ so a listener purges precisely what the responses were tagged with. On Vercel:
592
604
 
593
605
  ```ts
594
- nitroApp.hooks.hook('eponyme:entry:published', async ({ name, collection }) => {
595
- await purgeCdnCache(collection ? `/${collection.name}/${collection.slug}` : `/${name}`)
606
+ // server/plugins/purge.ts
607
+ import { invalidateByTag } from '@vercel/functions'
608
+
609
+ export default defineNitroPlugin((nitroApp) => {
610
+ const purge = async ({ name, collection }: { name: string, collection?: { name: string } }) => {
611
+ if (!import.meta.env.VERCEL) return
612
+ await invalidateByTag(getEponymeCacheTags(name, collection)).catch(() => {})
613
+ }
614
+
615
+ nitroApp.hooks.hook('eponyme:entry:published', purge)
616
+ nitroApp.hooks.hook('eponyme:entry:restored', purge)
617
+ nitroApp.hooks.hook('eponyme:entry:trashed', purge)
618
+ nitroApp.hooks.hook('eponyme:entry:untrashed', purge)
619
+ nitroApp.hooks.hook('eponyme:entry:purged', purge)
596
620
  })
597
621
  ```
598
622
 
623
+ Invalidating marks the entry stale and revalidates in the background, so no visitor waits
624
+ for it. A failed purge is swallowed: a CDN that cannot be reached must not turn a
625
+ successful publication into an error the editor cannot act on. The worst case is the
626
+ content appearing when `cdnCacheSeconds` expires on its own.
627
+
599
628
  **Only published content is ever cached.** Drafts, historical versions, `raw=1` reads,
600
629
  submissions, the trash, the user list and the session route all answer `no-store`. That is
601
630
  enforced by a server middleware that marks every Eponyme route uncacheable, which the few
@@ -806,7 +835,7 @@ a trash move leaves the entry itself untouched.
806
835
 
807
836
  ## Current status
808
837
 
809
- Eponyme is at version `0.4.0`. It is ready for controlled projects and production pilots. A few workflows still need hardening before a broad public release:
838
+ Eponyme is at version `0.5.0`. It is ready for controlled projects and production pilots. A few workflows still need hardening before a broad public release:
810
839
 
811
840
  - Client revision tokens for long-running concurrent edits: the current `updatedAt` check protects overlapping writes within one request, not two editors who loaded the same older revision. A deletion is not guarded by a revision either
812
841
  - Retention controls for the trash: entries stay there until someone empties it
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@karibsen/eponyme",
3
3
  "configKey": "eponyme",
4
- "version": "0.4.0",
4
+ "version": "0.5.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -106,7 +106,10 @@ Note that '~/' points at ${nuxt.options.srcDir}, so a server module needs '~~/'.
106
106
  { name: "getEponymeSitemapEntries", from: resolver.resolve("./runtime/server/utils/eponyme-sitemap") },
107
107
  // A `custom` form posts to the host application's own route, so this is what
108
108
  // keeps server-side validation as the security boundary.
109
- { name: "validateEponymeForm", from: resolver.resolve("./runtime/server/utils/eponyme-form") }
109
+ { name: "validateEponymeForm", from: resolver.resolve("./runtime/server/utils/eponyme-form") },
110
+ // The tags a CDN purge has to invalidate when an entry changes, matching exactly what
111
+ // the cached responses were tagged with.
112
+ { name: "getEponymeCacheTags", from: resolver.resolve("./runtime/server/utils/eponyme-cache") }
110
113
  ]);
111
114
  addRouteMiddleware({
112
115
  name: "eponyme-auth",
@@ -1,7 +1,8 @@
1
1
  import { createError, defineEventHandler, getQuery, getRequestURL } from "h3";
2
2
  import { useEponymeService } from "../../services/eponyme-service.js";
3
3
  import { requireEponymeUser } from "../../utils/auth.js";
4
- import { setEponymePublicCache } from "../../utils/eponyme-cache.js";
4
+ import { getEponymeCacheTags, setEponymePublicCache } from "../../utils/eponyme-cache.js";
5
+ import { splitEponymeCollectionEntry } from "../../utils/eponyme-entry.js";
5
6
  import { interpolateEponymeContent } from "../../utils/eponyme-variables.js";
6
7
  function readVersion(raw) {
7
8
  if (raw === "draft") return "draft";
@@ -14,7 +15,7 @@ export default defineEventHandler(async (event) => {
14
15
  const version = readVersion(requestedVersion);
15
16
  const raw = Boolean(getQuery(event).raw);
16
17
  if (version !== "published") await requireEponymeUser(event);
17
- else if (!raw) setEponymePublicCache(event);
18
+ else if (!raw) setEponymePublicCache(event, getEponymeCacheTags(name, splitEponymeCollectionEntry(useEponymeService(), name)?.name));
18
19
  const result = name ? await useEponymeService().getResult(name, version) : void 0;
19
20
  if (!result) throw createError({ statusCode: 404, statusMessage: "Eponyme entry not found." });
20
21
  const data = raw ? result.data : interpolateEponymeContent(result.data);
@@ -1,7 +1,7 @@
1
1
  import { createError, defineEventHandler, getQuery, getRequestURL } from "h3";
2
2
  import { useEponymeService } from "../../services/eponyme-service.js";
3
3
  import { requireEponymeUser } from "../../utils/auth.js";
4
- import { setEponymePublicCache } from "../../utils/eponyme-cache.js";
4
+ import { getEponymeCacheTags, setEponymePublicCache } from "../../utils/eponyme-cache.js";
5
5
  import { interpolateEponymeContent } from "../../utils/eponyme-variables.js";
6
6
  const MAX_TAKE = 200;
7
7
  function readCount(raw, min, max) {
@@ -15,7 +15,7 @@ export default defineEventHandler(async (event) => {
15
15
  const query = getQuery(event);
16
16
  const version = query.version === "draft" ? "draft" : "published";
17
17
  if (version === "draft") await requireEponymeUser(event);
18
- else if (!query.raw) setEponymePublicCache(event);
18
+ else if (!query.raw) setEponymePublicCache(event, getEponymeCacheTags(name));
19
19
  if (!name) throw createError({ statusCode: 404, statusMessage: "Eponyme collection not found." });
20
20
  const service = useEponymeService();
21
21
  const orderBy = query.orderBy === void 0 || query.orderBy === "" ? void 0 : String(query.orderBy);
@@ -1,7 +1,7 @@
1
1
  import { defineEventHandler } from "h3";
2
- import { setEponymePublicCache } from "../utils/eponyme-cache.js";
2
+ import { getEponymeCacheTags, setEponymePublicCache } from "../utils/eponyme-cache.js";
3
3
  import { getEponymeSitemapEntries } from "../utils/eponyme-sitemap.js";
4
4
  export default defineEventHandler(async (event) => {
5
- setEponymePublicCache(event);
5
+ setEponymePublicCache(event, getEponymeCacheTags("sitemap"));
6
6
  return { entries: await getEponymeSitemapEntries() };
7
7
  });
@@ -1,4 +1,18 @@
1
1
  import type { H3Event } from 'h3';
2
+ /**
3
+ * Cache tags for one entry, most specific first.
4
+ *
5
+ * A CDN cannot usually be purged by URL — Vercel and Cloudflare both purge by tag — and one
6
+ * entry appears in more than one cached response: its own, and its collection's listing.
7
+ * Tagging both is what lets publishing an article drop the article *and* the index that
8
+ * lists it.
9
+ *
10
+ * A comma is the delimiter every CDN uses for this header, so it is stripped rather than
11
+ * escaped: a tag containing one would silently become two.
12
+ */
13
+ export declare function getEponymeCacheTags(name: string, collection?: {
14
+ name: string;
15
+ } | string): string[];
2
16
  /**
3
17
  * Published content is identical for every visitor, so both the browser and the CDN may
4
18
  * hold on to it. The two windows are deliberately different:
@@ -8,7 +22,7 @@ import type { H3Event } from 'h3';
8
22
  * - `max-age` is the browser's, and a browser cache cannot be purged by anyone. Whatever
9
23
  * it holds is served until it expires, publication or not, which is why it stays short.
10
24
  */
11
- export declare function setEponymePublicCache(event: H3Event): void;
25
+ export declare function setEponymePublicCache(event: H3Event, tags?: string[]): void;
12
26
  /**
13
27
  * Drafts and historical versions are unreleased material. `no-store` rather than
14
28
  * `no-cache`: nothing may keep a copy at all, not even one it promises to revalidate.
@@ -1,7 +1,19 @@
1
1
  import { setResponseHeader } from "h3";
2
2
  import { useRuntimeConfig } from "nitropack/runtime";
3
3
  const STALE_WHILE_REVALIDATE_FACTOR = 12;
4
- export function setEponymePublicCache(event) {
4
+ export function getEponymeCacheTags(name, collection) {
5
+ const collectionName = typeof collection === "string" ? collection : collection?.name;
6
+ const tags = ["eponyme", `eponyme:${name}`];
7
+ if (collectionName) tags.push(`eponyme:${collectionName}`);
8
+ return [...new Set(tags)].map((tag) => tag.replace(/,/g, "").slice(0, 256));
9
+ }
10
+ function setCacheTags(event, tags) {
11
+ if (!tags.length) return;
12
+ const value = tags.join(",");
13
+ setResponseHeader(event, "Vercel-Cache-Tag", value);
14
+ setResponseHeader(event, "Cache-Tag", value);
15
+ }
16
+ export function setEponymePublicCache(event, tags = []) {
5
17
  const config = useRuntimeConfig().eponymeContent;
6
18
  const browser = Math.max(0, Math.trunc(config?.browserCacheSeconds ?? 30));
7
19
  const cdn = Math.max(0, Math.trunc(config?.cdnCacheSeconds ?? 300));
@@ -14,6 +26,7 @@ export function setEponymePublicCache(event) {
14
26
  "Cache-Control",
15
27
  `public, max-age=${browser}, s-maxage=${cdn}, stale-while-revalidate=${cdn * STALE_WHILE_REVALIDATE_FACTOR}`
16
28
  );
29
+ setCacheTags(event, tags);
17
30
  }
18
31
  export function setEponymePrivateCache(event) {
19
32
  setResponseHeader(event, "Cache-Control", "no-store");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karibsen/eponyme",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "A lightweight, type-safe content manager for Nuxt.",
5
5
  "license": "MIT",
6
6
  "type": "module",