@karibsen/eponyme 0.3.0 → 0.4.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 +44 -1
- package/dist/module.d.mts +12 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +7 -2
- package/dist/runtime/composables/useEponyme.js +2 -1
- package/dist/runtime/composables/useEponymeCollection.js +7 -2
- package/dist/runtime/server/api/eponyme/[name].get.js +6 -6
- package/dist/runtime/server/api/eponyme-collections/[path].get.js +2 -0
- package/dist/runtime/server/api/eponyme-sitemap.get.js +3 -1
- package/dist/runtime/server/middleware/eponyme-no-store.d.ts +14 -0
- package/dist/runtime/server/middleware/eponyme-no-store.js +6 -0
- package/dist/runtime/server/utils/eponyme-cache.d.ts +16 -0
- package/dist/runtime/server/utils/eponyme-cache.js +20 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -559,6 +559,49 @@ ${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
|
+
The CDN window can be long because it can be purged, and `eponyme:entry:published` is the
|
|
591
|
+
place to do it:
|
|
592
|
+
|
|
593
|
+
```ts
|
|
594
|
+
nitroApp.hooks.hook('eponyme:entry:published', async ({ name, collection }) => {
|
|
595
|
+
await purgeCdnCache(collection ? `/${collection.name}/${collection.slug}` : `/${name}`)
|
|
596
|
+
})
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
**Only published content is ever cached.** Drafts, historical versions, `raw=1` reads,
|
|
600
|
+
submissions, the trash, the user list and the session route all answer `no-store`. That is
|
|
601
|
+
enforced by a server middleware that marks every Eponyme route uncacheable, which the few
|
|
602
|
+
public ones override — so a route added later is private until someone decides otherwise,
|
|
603
|
+
rather than public until someone remembers.
|
|
604
|
+
|
|
562
605
|
## Forms
|
|
563
606
|
|
|
564
607
|
Use `form()` for public forms. A form owns a schema, its own routes and its anti-abuse
|
|
@@ -763,7 +806,7 @@ a trash move leaves the entry itself untouched.
|
|
|
763
806
|
|
|
764
807
|
## Current status
|
|
765
808
|
|
|
766
|
-
Eponyme is at version `0.
|
|
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:
|
|
767
810
|
|
|
768
811
|
- 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
812
|
- 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
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) {
|
|
@@ -151,6 +155,7 @@ declare module '#eponyme/captcha' {
|
|
|
151
155
|
}
|
|
152
156
|
`
|
|
153
157
|
});
|
|
158
|
+
addServerHandler({ middleware: true, handler: resolver.resolve("./runtime/server/middleware/eponyme-no-store") });
|
|
154
159
|
addServerHandler({ route: "/api/eponyme/**", handler: resolver.resolve("./runtime/server/api/eponyme/[name].get") });
|
|
155
160
|
addServerHandler({ route: "/api/eponyme/**", method: "patch", handler: resolver.resolve("./runtime/server/api/eponyme/[name].patch") });
|
|
156
161
|
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:
|
|
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
|
-
|
|
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)}`, {
|
|
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,7 @@
|
|
|
1
|
-
import { createError, defineEventHandler, getQuery, getRequestURL
|
|
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
5
|
import { interpolateEponymeContent } from "../../utils/eponyme-variables.js";
|
|
5
6
|
function readVersion(raw) {
|
|
6
7
|
if (raw === "draft") return "draft";
|
|
@@ -11,12 +12,11 @@ export default defineEventHandler(async (event) => {
|
|
|
11
12
|
const name = decodeURIComponent(getRequestURL(event).pathname.replace(/^\/api\/eponyme\//, ""));
|
|
12
13
|
const requestedVersion = getQuery(event).version;
|
|
13
14
|
const version = readVersion(requestedVersion);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
15
|
+
const raw = Boolean(getQuery(event).raw);
|
|
16
|
+
if (version !== "published") await requireEponymeUser(event);
|
|
17
|
+
else if (!raw) setEponymePublicCache(event);
|
|
18
18
|
const result = name ? await useEponymeService().getResult(name, version) : void 0;
|
|
19
19
|
if (!result) throw createError({ statusCode: 404, statusMessage: "Eponyme entry not found." });
|
|
20
|
-
const data =
|
|
20
|
+
const data = raw ? result.data : interpolateEponymeContent(result.data);
|
|
21
21
|
return requestedVersion ? { ...result, data } : { data };
|
|
22
22
|
});
|
|
@@ -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 { 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);
|
|
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 { 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);
|
|
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,16 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
/**
|
|
3
|
+
* Published content is identical for every visitor, so both the browser and the CDN may
|
|
4
|
+
* hold on to it. The two windows are deliberately different:
|
|
5
|
+
*
|
|
6
|
+
* - `s-maxage` is the CDN's, and a CDN can be purged — `eponyme:entry:published` is the
|
|
7
|
+
* hook to do it from, so this one can afford to be long.
|
|
8
|
+
* - `max-age` is the browser's, and a browser cache cannot be purged by anyone. Whatever
|
|
9
|
+
* it holds is served until it expires, publication or not, which is why it stays short.
|
|
10
|
+
*/
|
|
11
|
+
export declare function setEponymePublicCache(event: H3Event): void;
|
|
12
|
+
/**
|
|
13
|
+
* Drafts and historical versions are unreleased material. `no-store` rather than
|
|
14
|
+
* `no-cache`: nothing may keep a copy at all, not even one it promises to revalidate.
|
|
15
|
+
*/
|
|
16
|
+
export declare function setEponymePrivateCache(event: H3Event): void;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { setResponseHeader } from "h3";
|
|
2
|
+
import { useRuntimeConfig } from "nitropack/runtime";
|
|
3
|
+
const STALE_WHILE_REVALIDATE_FACTOR = 12;
|
|
4
|
+
export function setEponymePublicCache(event) {
|
|
5
|
+
const config = useRuntimeConfig().eponymeContent;
|
|
6
|
+
const browser = Math.max(0, Math.trunc(config?.browserCacheSeconds ?? 30));
|
|
7
|
+
const cdn = Math.max(0, Math.trunc(config?.cdnCacheSeconds ?? 300));
|
|
8
|
+
if (!browser && !cdn) {
|
|
9
|
+
setResponseHeader(event, "Cache-Control", "no-cache");
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
setResponseHeader(
|
|
13
|
+
event,
|
|
14
|
+
"Cache-Control",
|
|
15
|
+
`public, max-age=${browser}, s-maxage=${cdn}, stale-while-revalidate=${cdn * STALE_WHILE_REVALIDATE_FACTOR}`
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
export function setEponymePrivateCache(event) {
|
|
19
|
+
setResponseHeader(event, "Cache-Control", "no-store");
|
|
20
|
+
}
|