@karibsen/eponyme 0.3.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
@@ -559,6 +559,78 @@ ${entries.map(entry => ` <url><loc>https://example.com${entry.loc}</loc>${entry
559
559
  })
560
560
  ```
561
561
 
562
+ ## Caching
563
+
564
+ Published content is the same for every visitor, so it is cached at three levels. The
565
+ defaults suit a content site and can be tuned or switched off:
566
+
567
+ ```ts
568
+ export default defineNuxtConfig({
569
+ eponyme: {
570
+ prismaClient: '~~/server/utils/prisma',
571
+ cacheSeconds: 5, // server instance
572
+ browserCacheSeconds: 30, // visitor's browser
573
+ cdnCacheSeconds: 300, // CDN or edge
574
+ },
575
+ })
576
+ ```
577
+
578
+ | Option | Who holds the copy | Purgeable |
579
+ |---|---|---|
580
+ | `cacheSeconds` | The server instance, in memory | Yes — cleared on every write |
581
+ | `browserCacheSeconds` | The visitor's browser | **No** |
582
+ | `cdnCacheSeconds` | The CDN | Yes — see below |
583
+
584
+ `browserCacheSeconds` is what makes a client-side navigation instant: the browser answers
585
+ from its own cache instead of crossing the network. It is also the one window nobody can
586
+ shorten. A visitor holding a copy keeps it until it expires, publication or not, which is
587
+ why the default is deliberately small. Set it to `0` if a publication must be visible
588
+ immediately to everyone; navigation then costs a round trip again.
589
+
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:
604
+
605
+ ```ts
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)
620
+ })
621
+ ```
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
+
628
+ **Only published content is ever cached.** Drafts, historical versions, `raw=1` reads,
629
+ submissions, the trash, the user list and the session route all answer `no-store`. That is
630
+ enforced by a server middleware that marks every Eponyme route uncacheable, which the few
631
+ public ones override — so a route added later is private until someone decides otherwise,
632
+ rather than public until someone remembers.
633
+
562
634
  ## Forms
563
635
 
564
636
  Use `form()` for public forms. A form owns a schema, its own routes and its anti-abuse
@@ -763,7 +835,7 @@ a trash move leaves the entry itself untouched.
763
835
 
764
836
  ## Current status
765
837
 
766
- Eponyme is at version `0.3.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:
767
839
 
768
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
769
841
  - Retention controls for the trash: entries stay there until someone empties it
package/dist/module.d.mts CHANGED
@@ -26,6 +26,18 @@ interface ModuleOptions {
26
26
  * another instance can still serve the previous content. `0` disables the cache.
27
27
  */
28
28
  cacheSeconds?: number;
29
+ /**
30
+ * How long a browser may reuse published content, in seconds. This is what makes a
31
+ * client-side navigation instant, and it is the one window nobody can purge: a visitor
32
+ * who already holds a copy keeps it until it expires. Keep it short. `0` disables it.
33
+ */
34
+ browserCacheSeconds?: number;
35
+ /**
36
+ * How long a CDN may reuse published content, in seconds. A CDN can be purged — the
37
+ * `eponyme:entry:published` hook is the place to do it — so this can be generous.
38
+ * `0` disables it.
39
+ */
40
+ cdnCacheSeconds?: number;
29
41
  }
30
42
  declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
31
43
 
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@karibsen/eponyme",
3
3
  "configKey": "eponyme",
4
- "version": "0.3.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
@@ -16,7 +16,9 @@ const module$1 = defineNuxtModule({
16
16
  auth: {
17
17
  sessionDurationDays: 7
18
18
  },
19
- cacheSeconds: 5
19
+ cacheSeconds: 5,
20
+ browserCacheSeconds: 30,
21
+ cdnCacheSeconds: 300
20
22
  },
21
23
  moduleDependencies: {
22
24
  "@nuxt/icon": {
@@ -72,7 +74,9 @@ const module$1 = defineNuxtModule({
72
74
  sessionDurationDays: options.auth?.sessionDurationDays ?? 7
73
75
  };
74
76
  nuxt.options.runtimeConfig.eponymeContent = {
75
- cacheSeconds: options.cacheSeconds ?? 5
77
+ cacheSeconds: options.cacheSeconds ?? 5,
78
+ browserCacheSeconds: options.browserCacheSeconds ?? 30,
79
+ cdnCacheSeconds: options.cdnCacheSeconds ?? 300
76
80
  };
77
81
  const preparing = Boolean(nuxt.options._prepare);
78
82
  if (!options.prismaClient) {
@@ -102,7 +106,10 @@ Note that '~/' points at ${nuxt.options.srcDir}, so a server module needs '~~/'.
102
106
  { name: "getEponymeSitemapEntries", from: resolver.resolve("./runtime/server/utils/eponyme-sitemap") },
103
107
  // A `custom` form posts to the host application's own route, so this is what
104
108
  // keeps server-side validation as the security boundary.
105
- { 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") }
106
113
  ]);
107
114
  addRouteMiddleware({
108
115
  name: "eponyme-auth",
@@ -151,6 +158,7 @@ declare module '#eponyme/captcha' {
151
158
  }
152
159
  `
153
160
  });
161
+ addServerHandler({ middleware: true, handler: resolver.resolve("./runtime/server/middleware/eponyme-no-store") });
154
162
  addServerHandler({ route: "/api/eponyme/**", handler: resolver.resolve("./runtime/server/api/eponyme/[name].get") });
155
163
  addServerHandler({ route: "/api/eponyme/**", method: "patch", handler: resolver.resolve("./runtime/server/api/eponyme/[name].patch") });
156
164
  addServerHandler({ route: "/api/eponyme-statuses", handler: resolver.resolve("./runtime/server/api/eponyme-statuses.get") });
@@ -10,9 +10,10 @@ export function useEponyme(name, options = {}) {
10
10
  const saving = ref(false);
11
11
  const requestFetch = useRequestFetch();
12
12
  const cacheKey = `eponyme:${name}:${version}${options.raw ? ":raw" : ""}`;
13
+ const fetchCache = version === "published" && !options.raw ? void 0 : "no-store";
13
14
  const { data: response, pending: loading, error, refresh: load } = useAsyncData(
14
15
  cacheKey,
15
- () => requestFetch(`/api/eponyme/${name}`, { query: { version, raw: options.raw ? 1 : void 0 }, cache: "no-store" }),
16
+ () => requestFetch(`/api/eponyme/${name}`, { query: { version, raw: options.raw ? 1 : void 0 }, cache: fetchCache }),
16
17
  { getCachedData: cacheDuringHydrationOnly }
17
18
  );
18
19
  const data = computed(() => response.value?.data);
@@ -15,7 +15,8 @@ export function useEponymeCollection(name, options = {}) {
15
15
  // The options belong in the key, otherwise two differently sorted calls to the
16
16
  // same collection would share one cache entry.
17
17
  `eponyme:collection:public:${name}:${options.take ?? ""}:${options.skip ?? ""}:${options.orderBy ?? ""}:${options.order ?? ""}`,
18
- () => requestFetch(`/api/eponyme-collections/${name}`, { query, cache: "no-store" }),
18
+ // Always the published listing, which is publicly cacheable: the browser cache may answer it.
19
+ () => requestFetch(`/api/eponyme-collections/${name}`, { query }),
19
20
  { getCachedData: cacheDuringHydrationOnly }
20
21
  );
21
22
  const entries = computed(() => result.data.value?.entries ?? []);
@@ -37,7 +38,11 @@ export function useEponymeCollectionEntry(name, slug, options = {}) {
37
38
  const requestFetch = useRequestFetch();
38
39
  const result = useAsyncData(
39
40
  `eponyme:collection-entry:${name}:${slug}:${version}`,
40
- () => requestFetch(`/api/eponyme/${name}/${encodeURIComponent(slug)}`, { query: { version }, cache: "no-store" }),
41
+ () => requestFetch(`/api/eponyme/${name}/${encodeURIComponent(slug)}`, {
42
+ query: { version },
43
+ // A draft or a historical version must never be stored, whatever the server says.
44
+ cache: version === "published" ? void 0 : "no-store"
45
+ }),
41
46
  { getCachedData: cacheDuringHydrationOnly }
42
47
  );
43
48
  if (import.meta.client) useEventListener(window, "focus", () => void result.refresh());
@@ -1,6 +1,8 @@
1
- import { createError, defineEventHandler, getQuery, getRequestURL, setResponseHeader } from "h3";
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 { getEponymeCacheTags, setEponymePublicCache } from "../../utils/eponyme-cache.js";
5
+ import { splitEponymeCollectionEntry } from "../../utils/eponyme-entry.js";
4
6
  import { interpolateEponymeContent } from "../../utils/eponyme-variables.js";
5
7
  function readVersion(raw) {
6
8
  if (raw === "draft") return "draft";
@@ -11,12 +13,11 @@ export default defineEventHandler(async (event) => {
11
13
  const name = decodeURIComponent(getRequestURL(event).pathname.replace(/^\/api\/eponyme\//, ""));
12
14
  const requestedVersion = getQuery(event).version;
13
15
  const version = readVersion(requestedVersion);
14
- if (version !== "published") {
15
- await requireEponymeUser(event);
16
- setResponseHeader(event, "Cache-Control", "no-store");
17
- }
16
+ const raw = Boolean(getQuery(event).raw);
17
+ if (version !== "published") await requireEponymeUser(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
- const data = getQuery(event).raw ? result.data : interpolateEponymeContent(result.data);
21
+ const data = raw ? result.data : interpolateEponymeContent(result.data);
21
22
  return requestedVersion ? { ...result, data } : { data };
22
23
  });
@@ -1,6 +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 { getEponymeCacheTags, setEponymePublicCache } from "../../utils/eponyme-cache.js";
4
5
  import { interpolateEponymeContent } from "../../utils/eponyme-variables.js";
5
6
  const MAX_TAKE = 200;
6
7
  function readCount(raw, min, max) {
@@ -14,6 +15,7 @@ export default defineEventHandler(async (event) => {
14
15
  const query = getQuery(event);
15
16
  const version = query.version === "draft" ? "draft" : "published";
16
17
  if (version === "draft") await requireEponymeUser(event);
18
+ else if (!query.raw) setEponymePublicCache(event, getEponymeCacheTags(name));
17
19
  if (!name) throw createError({ statusCode: 404, statusMessage: "Eponyme collection not found." });
18
20
  const service = useEponymeService();
19
21
  const orderBy = query.orderBy === void 0 || query.orderBy === "" ? void 0 : String(query.orderBy);
@@ -1,5 +1,7 @@
1
1
  import { defineEventHandler } from "h3";
2
+ import { getEponymeCacheTags, setEponymePublicCache } from "../utils/eponyme-cache.js";
2
3
  import { getEponymeSitemapEntries } from "../utils/eponyme-sitemap.js";
3
- export default defineEventHandler(async () => {
4
+ export default defineEventHandler(async (event) => {
5
+ setEponymePublicCache(event, getEponymeCacheTags("sitemap"));
4
6
  return { entries: await getEponymeSitemapEntries() };
5
7
  });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Every Eponyme route is uncacheable until it says otherwise.
3
+ *
4
+ * The routes that may be cached are a short, deliberate list — published content, the
5
+ * collection listings, the sitemap — and each calls `setEponymePublicCache` to override
6
+ * this. Everything else carries sessions, drafts, trashed entries, form submissions or
7
+ * the user list, and none of it may ever be stored by a browser or a CDN.
8
+ *
9
+ * Written as a default rather than as a header on each handler so that the dangerous
10
+ * direction is the one that takes effort: a route added later is private until someone
11
+ * decides otherwise, instead of public until someone remembers.
12
+ */
13
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, void>;
14
+ export default _default;
@@ -0,0 +1,6 @@
1
+ import { defineEventHandler, getRequestURL } from "h3";
2
+ import { setEponymePrivateCache } from "../utils/eponyme-cache.js";
3
+ export default defineEventHandler((event) => {
4
+ if (!getRequestURL(event).pathname.startsWith("/api/eponyme")) return;
5
+ setEponymePrivateCache(event);
6
+ });
@@ -0,0 +1,30 @@
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[];
16
+ /**
17
+ * Published content is identical for every visitor, so both the browser and the CDN may
18
+ * hold on to it. The two windows are deliberately different:
19
+ *
20
+ * - `s-maxage` is the CDN's, and a CDN can be purged — `eponyme:entry:published` is the
21
+ * hook to do it from, so this one can afford to be long.
22
+ * - `max-age` is the browser's, and a browser cache cannot be purged by anyone. Whatever
23
+ * it holds is served until it expires, publication or not, which is why it stays short.
24
+ */
25
+ export declare function setEponymePublicCache(event: H3Event, tags?: string[]): void;
26
+ /**
27
+ * Drafts and historical versions are unreleased material. `no-store` rather than
28
+ * `no-cache`: nothing may keep a copy at all, not even one it promises to revalidate.
29
+ */
30
+ export declare function setEponymePrivateCache(event: H3Event): void;
@@ -0,0 +1,33 @@
1
+ import { setResponseHeader } from "h3";
2
+ import { useRuntimeConfig } from "nitropack/runtime";
3
+ const STALE_WHILE_REVALIDATE_FACTOR = 12;
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 = []) {
17
+ const config = useRuntimeConfig().eponymeContent;
18
+ const browser = Math.max(0, Math.trunc(config?.browserCacheSeconds ?? 30));
19
+ const cdn = Math.max(0, Math.trunc(config?.cdnCacheSeconds ?? 300));
20
+ if (!browser && !cdn) {
21
+ setResponseHeader(event, "Cache-Control", "no-cache");
22
+ return;
23
+ }
24
+ setResponseHeader(
25
+ event,
26
+ "Cache-Control",
27
+ `public, max-age=${browser}, s-maxage=${cdn}, stale-while-revalidate=${cdn * STALE_WHILE_REVALIDATE_FACTOR}`
28
+ );
29
+ setCacheTags(event, tags);
30
+ }
31
+ export function setEponymePrivateCache(event) {
32
+ setResponseHeader(event, "Cache-Control", "no-store");
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karibsen/eponyme",
3
- "version": "0.3.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",