@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 +34 -5
- package/dist/module.json +1 -1
- package/dist/module.mjs +4 -1
- package/dist/runtime/server/api/eponyme/[name].get.js +3 -2
- package/dist/runtime/server/api/eponyme-collections/[path].get.js +2 -2
- package/dist/runtime/server/api/eponyme-sitemap.get.js +2 -2
- package/dist/runtime/server/utils/eponyme-cache.d.ts +15 -1
- package/dist/runtime/server/utils/eponyme-cache.js +14 -1
- package/package.json +1 -1
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
|
-
|
|
591
|
-
|
|
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
|
-
|
|
595
|
-
|
|
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.
|
|
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
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
|
|
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");
|