@hollware/nuxt 0.6.0 → 0.8.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/dist/index.d.mts CHANGED
@@ -29,6 +29,21 @@ export interface ModuleOptions {
29
29
  * No views are counted when it is not set.
30
30
  */
31
31
  beacon?: string;
32
+ /**
33
+ * How the pages that read Hollware are kept (S8):
34
+ *
35
+ * - `'purge'`: in Cloudflare's Workers Cache, which serves them without running the Worker, each
36
+ * tagged with what it showed, until Hollware's webhook to `/_hollware/webhook` (signed with
37
+ * `NUXT_HOLLWARE_WEBHOOK_SECRET`) purges what changed: a publish, an edit or an unpublish shows
38
+ * in seconds everywhere. It needs `"cache": { "enabled": true }` in the site's wrangler.jsonc.
39
+ * - a number of seconds: kept by Nitro (`swr`) in each isolate, and rendered again in the
40
+ * background once that old; a change shows within that time.
41
+ *
42
+ * Errors are never kept. Off when not set.
43
+ */
44
+ cache?: number | 'purge';
45
+ /** The secret of Hollware's webhook to this site, for `cache: 'purge'`; prefer `NUXT_HOLLWARE_WEBHOOK_SECRET`. */
46
+ webhookSecret?: string;
32
47
  }
33
48
  export declare const name = "@hollware/nuxt";
34
49
  /** The routes of the site that read Hollware for its pages, on the server. */
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { addComponent, addImports, addPlugin, addServerHandler, addServerImports, createResolver, defineNuxtModule } from "@nuxt/kit";
1
+ import { addComponent, addImports, addPlugin, addServerHandler, addServerImports, addServerPlugin, createResolver, defineNuxtModule } from "@nuxt/kit";
2
2
  //#region src/index.ts
3
3
  const name = "@hollware/nuxt";
4
4
  /** The routes of the site that read Hollware for its pages, on the server. */
@@ -27,7 +27,8 @@ var src_default = defineNuxtModule({
27
27
  site: options.site,
28
28
  binding: options.binding,
29
29
  url: options.url ?? "",
30
- token: options.token ?? ""
30
+ token: options.token ?? "",
31
+ webhookSecret: options.webhookSecret ?? ""
31
32
  };
32
33
  addServerImports([{
33
34
  name: "useHollware",
@@ -52,6 +53,26 @@ var src_default = defineNuxtModule({
52
53
  filePath: resolve("./runtime/components/HollwareContent")
53
54
  });
54
55
  if (options.generator) addPlugin(resolve("./runtime/generator"));
56
+ if (options.cache === "purge") {
57
+ addServerPlugin(resolve("./runtime/server/cache"));
58
+ addServerHandler({
59
+ route: "/_hollware/webhook",
60
+ method: "post",
61
+ handler: resolve("./runtime/server/routes/webhook")
62
+ });
63
+ }
64
+ if (typeof options.cache === "number" && options.cache > 0) {
65
+ const pages = { swr: options.cache };
66
+ const routes = {
67
+ swr: false,
68
+ cache: false
69
+ };
70
+ nuxt.options.routeRules = {
71
+ "/**": pages,
72
+ "/_hollware/**": routes,
73
+ ...nuxt.options.routeRules
74
+ };
75
+ }
55
76
  if (options.beacon !== void 0 && options.beacon !== "") {
56
77
  nuxt.options.runtimeConfig.public["hollware"] = {
57
78
  site: options.site,
@@ -1,11 +1,6 @@
1
1
  import { PageEntry } from "./types.mjs";
2
2
  import { MaybeRefOrGetter } from "vue";
3
3
  //#region src/runtime/composables.d.ts
4
- /**
5
- * The published content of the site, for a page: each reads the site's own `/_hollware/` route,
6
- * which reads Hollware on the server, so no token reaches the browser. They are `useFetch`, so the
7
- * page renders on the server with the answer and the browser does not ask again.
8
- */
9
4
  /** An entry, with its Comark document; `data` is null while it loads, `error` a 404 when it is not published. */
10
5
  export declare function useHollwareEntry(collection: MaybeRefOrGetter<string>, locale: MaybeRefOrGetter<string>, slug: MaybeRefOrGetter<string>): import("nuxt/app").AsyncData<{
11
6
  outcome: 'ok';
@@ -38,6 +33,7 @@ export declare function useHollwareSite(): import("nuxt/app").AsyncData<{
38
33
  key: string;
39
34
  locales: string[];
40
35
  defaultLocale: string;
36
+ url: string | null;
41
37
  styleGuide: string | null;
42
38
  collections: {
43
39
  name: string;
@@ -1,32 +1,58 @@
1
- import { useFetch } from "nuxt/app";
1
+ import { entryTags, groupTags, listTags, recordTags, siteTags } from "./tags.mjs";
2
+ import { useFetch, useRequestEvent } from "nuxt/app";
2
3
  import { computed, toValue } from "vue";
3
4
  //#region src/runtime/composables.ts
4
5
  /**
5
6
  * The published content of the site, for a page: each reads the site's own `/_hollware/` route,
6
7
  * which reads Hollware on the server, so no token reaches the browser. They are `useFetch`, so the
7
- * page renders on the server with the answer and the browser does not ask again.
8
+ * page renders on the server with the answer and the browser does not ask again. On the server,
9
+ * each records on the page's request the cache tags of what it read (S8).
8
10
  */
11
+ /** The request of the page being rendered, on the server; none in the browser. */
12
+ function pageRequest() {
13
+ return import.meta.server ? useRequestEvent() : void 0;
14
+ }
9
15
  /** An entry, with its Comark document; `data` is null while it loads, `error` a 404 when it is not published. */
10
16
  function useHollwareEntry(collection, locale, slug) {
11
17
  const path = computed(() => `/_hollware/entry/${encodeURIComponent(toValue(collection))}/${encodeURIComponent(toValue(locale))}/${encodeURIComponent(toValue(slug))}`);
12
- return useFetch(path, { key: path.value });
18
+ const event = pageRequest();
19
+ return useFetch(path, {
20
+ key: path.value,
21
+ transform: (answer) => {
22
+ recordTags(event, entryTags(answer.entry));
23
+ return answer;
24
+ }
25
+ });
13
26
  }
14
27
  /** A page of the published entries of a collection, newest first. */
15
28
  function useHollwareEntries(collection, options = {}, fetching = {}) {
16
29
  const path = computed(() => `/_hollware/entries/${encodeURIComponent(toValue(collection))}`);
30
+ const event = pageRequest();
17
31
  return useFetch(path, {
18
32
  query: computed(() => toValue(options)),
19
- ...fetching
33
+ ...fetching,
34
+ transform: (answer) => {
35
+ recordTags(event, listTags(toValue(collection), toValue(options).locale, answer.entries));
36
+ return answer;
37
+ }
20
38
  });
21
39
  }
22
40
  /** The entries of a translation group, one per locale. */
23
41
  function useHollwareTranslations(collection, group) {
24
42
  const path = computed(() => `/_hollware/translations/${encodeURIComponent(toValue(collection))}/${encodeURIComponent(toValue(group))}`);
25
- return useFetch(path);
43
+ const event = pageRequest();
44
+ return useFetch(path, { transform: (answer) => {
45
+ recordTags(event, groupTags(toValue(collection), toValue(group), answer.entries));
46
+ return answer;
47
+ } });
26
48
  }
27
49
  /** The site: its locales, its collections and their schemas. */
28
50
  function useHollwareSite() {
29
- return useFetch("/_hollware/site");
51
+ const event = pageRequest();
52
+ return useFetch("/_hollware/site", { transform: (answer) => {
53
+ recordTags(event, siteTags());
54
+ return answer;
55
+ } });
30
56
  }
31
57
  //#endregion
32
58
  export { useHollwareEntries, useHollwareEntry, useHollwareSite, useHollwareTranslations };
@@ -0,0 +1,10 @@
1
+ //#region src/runtime/server/cache.d.ts
2
+ /**
3
+ * The cache of `hollware.cache: 'purge'` (S8): a response that read Hollware and answered 200 is
4
+ * kept by Cloudflare's Workers Cache, which serves it again without running the Worker, tagged with
5
+ * what it showed, until an event of Hollware purges one of its tags (`/_hollware/webhook`). The
6
+ * browser keeps nothing: only the edge does (`cloudflare-cdn-cache-control`).
7
+ */
8
+ declare const _default: import("nitropack").NitroAppPlugin;
9
+ //#endregion
10
+ export { _default as default };
@@ -0,0 +1,17 @@
1
+ import { edgeHeaders, tagsOf } from "../tags.mjs";
2
+ import { defineNitroPlugin } from "nitropack/runtime";
3
+ //#region src/runtime/server/cache.ts
4
+ /**
5
+ * The cache of `hollware.cache: 'purge'` (S8): a response that read Hollware and answered 200 is
6
+ * kept by Cloudflare's Workers Cache, which serves it again without running the Worker, tagged with
7
+ * what it showed, until an event of Hollware purges one of its tags (`/_hollware/webhook`). The
8
+ * browser keeps nothing: only the edge does (`cloudflare-cdn-cache-control`).
9
+ */
10
+ var cache_default = defineNitroPlugin((nitroApp) => {
11
+ nitroApp.hooks.hook("beforeResponse", (event) => {
12
+ const headers = edgeHeaders(tagsOf(event), event.node.res.statusCode, event.method);
13
+ for (const [name, value] of Object.entries(headers ?? {})) event.node.res.setHeader(name, value);
14
+ });
15
+ });
16
+ //#endregion
17
+ export { cache_default as default };
@@ -1,3 +1,4 @@
1
+ import { entryTags, groupTags, listTags, recordTags, siteTags } from "../tags.mjs";
1
2
  import { createClient } from "@hollware/client";
2
3
  import { createError } from "h3";
3
4
  import { useRuntimeConfig } from "nitropack/runtime";
@@ -16,19 +17,48 @@ function useHollware(event) {
16
17
  const binding = typeof env === "object" && env !== null ? Reflect.get(env, config.binding) : void 0;
17
18
  if (isReader(binding)) return {
18
19
  site: config.site,
19
- reader: createClient({ binding })
20
+ reader: tagging(event, createClient({ binding }))
20
21
  };
21
22
  if (config.url !== "" && config.token !== "") return {
22
23
  site: config.site,
23
- reader: createClient({
24
+ reader: tagging(event, createClient({
24
25
  url: config.url,
25
26
  token: config.token
26
- })
27
+ }))
27
28
  };
28
29
  throw createError({
29
30
  statusCode: 500,
30
31
  statusMessage: `Hollware: the Worker has no binding ${config.binding}, and hollware.url with a read token (NUXT_HOLLWARE_TOKEN) is not set.`
31
32
  });
32
33
  }
34
+ /**
35
+ * The reader, recording on the request the cache tags of what each read answered (S8): a route of
36
+ * the site's own that reads through `useHollware` is tagged with no work of its own.
37
+ */
38
+ function tagging(event, reader) {
39
+ return {
40
+ ...reader,
41
+ getEntry: async (...args) => {
42
+ const answer = await reader.getEntry(...args);
43
+ recordTags(event, entryTags(answer.entry));
44
+ return answer;
45
+ },
46
+ listEntries: async (...args) => {
47
+ const answer = await reader.listEntries(...args);
48
+ recordTags(event, listTags(args[1], args[2]?.locale, answer.entries));
49
+ return answer;
50
+ },
51
+ getTranslations: async (...args) => {
52
+ const answer = await reader.getTranslations(...args);
53
+ recordTags(event, groupTags(args[1], args[2], answer.entries ?? []));
54
+ return answer;
55
+ },
56
+ describeSite: async (...args) => {
57
+ const answer = await reader.describeSite(...args);
58
+ recordTags(event, siteTags());
59
+ return answer;
60
+ }
61
+ };
62
+ }
33
63
  //#endregion
34
64
  export { useHollware };
@@ -1,7 +1,7 @@
1
1
  //#region src/runtime/server/routes/entries.d.ts
2
2
  declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
3
3
  outcome: "ok";
4
- entries: (Record<string, unknown> | undefined)[];
4
+ entries: Record<string, unknown>[];
5
5
  nextCursor: string | null;
6
6
  }>>;
7
7
  //#endregion
@@ -19,9 +19,10 @@ var entries_default = defineEventHandler(async (event) => {
19
19
  ...limit === void 0 ? {} : { limit: Number(limit) }
20
20
  };
21
21
  const page = await answer(event, () => reader.listEntries(site, getRouterParam(event, "collection") ?? "", options));
22
+ const entries = page.entries.map(forPage).flatMap((entry) => entry === void 0 ? [] : [entry]);
22
23
  return {
23
24
  outcome: page.outcome,
24
- entries: page.entries.map(forPage),
25
+ entries,
25
26
  nextCursor: page.nextCursor
26
27
  };
27
28
  });
@@ -5,6 +5,7 @@ declare const _default: import("h3").EventHandler<import("h3").EventHandlerReque
5
5
  key: string;
6
6
  locales: string[];
7
7
  defaultLocale: string;
8
+ url: string | null;
8
9
  styleGuide: string | null;
9
10
  collections: {
10
11
  name: string;
@@ -1,7 +1,7 @@
1
1
  //#region src/runtime/server/routes/translations.d.ts
2
2
  declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
3
3
  outcome: "not_found" | "ok";
4
- entries: (Record<string, unknown> | undefined)[];
4
+ entries: Record<string, unknown>[];
5
5
  }>>;
6
6
  //#endregion
7
7
  export { _default as default };
@@ -5,9 +5,10 @@ import { defineEventHandler, getRouterParam } from "h3";
5
5
  var translations_default = defineEventHandler(async (event) => {
6
6
  const { site, reader } = useHollware(event);
7
7
  const group = await answer(event, () => reader.getTranslations(site, getRouterParam(event, "collection") ?? "", getRouterParam(event, "group") ?? ""));
8
+ const entries = (group.entries ?? []).map(forPage).flatMap((entry) => entry === void 0 ? [] : [entry]);
8
9
  return {
9
10
  outcome: group.outcome,
10
- entries: (group.entries ?? []).map(forPage)
11
+ entries
11
12
  };
12
13
  });
13
14
  //#endregion
@@ -0,0 +1,6 @@
1
+ //#region src/runtime/server/routes/webhook.d.ts
2
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
3
+ purged: string[];
4
+ }>>;
5
+ //#endregion
6
+ export { _default as default };
@@ -0,0 +1,38 @@
1
+ import { verifyWebhook } from "@hollware/core";
2
+ import { createError, defineEventHandler, getRequestHeaders, readRawBody } from "h3";
3
+ import { useRuntimeConfig } from "nitropack/runtime";
4
+ //#region src/runtime/server/routes/webhook.ts
5
+ function cacheOf(context) {
6
+ const cloudflare = typeof context === "object" && context !== null ? Reflect.get(context, "cloudflare") : void 0;
7
+ const ctx = typeof cloudflare === "object" && cloudflare !== null ? Reflect.get(cloudflare, "context") : void 0;
8
+ const cache = typeof ctx === "object" && ctx !== null ? Reflect.get(ctx, "cache") : void 0;
9
+ return typeof cache === "object" && cache !== null && typeof Reflect.get(cache, "purge") === "function" ? { purge: (options) => Reflect.apply(Reflect.get(cache, "purge"), cache, [options]) } : void 0;
10
+ }
11
+ var webhook_default = defineEventHandler(async (event) => {
12
+ const config = useRuntimeConfig(event);
13
+ const secret = String(Reflect.get(Reflect.get(config, "hollware") ?? {}, "webhookSecret") ?? "");
14
+ const body = await readRawBody(event, "utf8") ?? "";
15
+ const headers = new Headers(Object.entries(getRequestHeaders(event)).flatMap(([name, value]) => value === void 0 ? [] : [[name, value]]));
16
+ const now = Math.floor(Date.now() / 1e3);
17
+ if (secret === "" || !await verifyWebhook({
18
+ secret,
19
+ body,
20
+ headers,
21
+ now
22
+ })) throw createError({
23
+ statusCode: 401,
24
+ statusMessage: "The signature of the event does not hold"
25
+ });
26
+ const payload = JSON.parse(body);
27
+ const found = typeof payload === "object" && payload !== null ? Reflect.get(payload, "cacheTags") : void 0;
28
+ const tags = Array.isArray(found) ? found.filter((tag) => typeof tag === "string") : [];
29
+ const cache = cacheOf(event.context);
30
+ if (cache === void 0) throw createError({
31
+ statusCode: 501,
32
+ statusMessage: "This Worker has no cache to purge: set \"cache\": { \"enabled\": true } in wrangler.jsonc"
33
+ });
34
+ if (tags.length > 0) await cache.purge({ tags });
35
+ return { purged: tags };
36
+ });
37
+ //#endregion
38
+ export { webhook_default as default };
@@ -0,0 +1,26 @@
1
+ //#region src/runtime/tags.d.ts
2
+ /** What a request holds of its own, whether Nuxt's or h3's: its context. */
3
+ interface Tagged {
4
+ readonly context: Record<string, unknown>;
5
+ }
6
+ export declare function recordTags(event: Tagged | undefined, tags: readonly string[]): void;
7
+ export declare function tagsOf(event: Tagged): string[];
8
+ interface Entryish {
9
+ readonly id?: unknown;
10
+ }
11
+ /** The tags of an entry read alone. */
12
+ export declare function entryTags(entry: Entryish | undefined): string[];
13
+ /** The tags of a list: the list itself, in its locale or every one, and each entry it shows. */
14
+ export declare function listTags(collection: string, locale: string | undefined, entries: readonly Entryish[]): string[];
15
+ /** The tags of the translations of a group. */
16
+ export declare function groupTags(collection: string, group: string, entries: readonly Entryish[]): string[];
17
+ export declare function siteTags(): string[];
18
+ /** How long the edge keeps a page no event purges: a year, the most it keeps anything. */
19
+ export declare const EDGE_SECONDS = 31536000;
20
+ /**
21
+ * The headers that let the edge keep a response, tagged with what it showed: only a GET that
22
+ * answered 200 and read Hollware. An error, a redirect or a write is never kept, nor a response
23
+ * that read nothing to purge it by. The browser keeps nothing; only the edge does.
24
+ */
25
+ export declare function edgeHeaders(tags: readonly string[], status: number, method: string): Readonly<Record<string, string>> | null;
26
+ //#endregion
@@ -0,0 +1,55 @@
1
+ import { EVERY_LOCALE, SITE_TAG, entryTag, groupTag, listTag } from "@hollware/core";
2
+ //#region src/runtime/tags.ts
3
+ /**
4
+ * The cache tags of a response (S8): what it showed of Hollware, in the names of the contract of
5
+ * `@hollware/core`, gathered on its request as each read answers, and written as `Cache-Tag` once
6
+ * it is rendered. A page gathers the tags of every read it made; a route of `/_hollware/` those of
7
+ * its own.
8
+ */
9
+ const KEY = "hollwareTags";
10
+ function recordTags(event, tags) {
11
+ if (event === void 0 || tags.length === 0) return;
12
+ const found = event.context[KEY];
13
+ const set = found instanceof Set ? found : /* @__PURE__ */ new Set();
14
+ for (const tag of tags) set.add(tag);
15
+ event.context[KEY] = set;
16
+ }
17
+ function tagsOf(event) {
18
+ const found = event.context[KEY];
19
+ return found instanceof Set ? [...found].map(String) : [];
20
+ }
21
+ function idsOf(entries) {
22
+ return entries.flatMap((entry) => typeof entry.id === "string" ? [entryTag(entry.id)] : []);
23
+ }
24
+ /** The tags of an entry read alone. */
25
+ function entryTags(entry) {
26
+ return entry === void 0 ? [] : idsOf([entry]);
27
+ }
28
+ /** The tags of a list: the list itself, in its locale or every one, and each entry it shows. */
29
+ function listTags(collection, locale, entries) {
30
+ return [listTag(collection, locale ?? EVERY_LOCALE), ...idsOf(entries)];
31
+ }
32
+ /** The tags of the translations of a group. */
33
+ function groupTags(collection, group, entries) {
34
+ return [groupTag(collection, group), ...idsOf(entries)];
35
+ }
36
+ function siteTags() {
37
+ return [SITE_TAG];
38
+ }
39
+ /** How long the edge keeps a page no event purges: a year, the most it keeps anything. */
40
+ const EDGE_SECONDS = 31536e3;
41
+ /**
42
+ * The headers that let the edge keep a response, tagged with what it showed: only a GET that
43
+ * answered 200 and read Hollware. An error, a redirect or a write is never kept, nor a response
44
+ * that read nothing to purge it by. The browser keeps nothing; only the edge does.
45
+ */
46
+ function edgeHeaders(tags, status, method) {
47
+ if (tags.length === 0 || status !== 200 || method !== "GET") return null;
48
+ return {
49
+ "cache-tag": tags.join(","),
50
+ "cache-control": "public, max-age=0, must-revalidate",
51
+ "cloudflare-cdn-cache-control": `max-age=${EDGE_SECONDS}`
52
+ };
53
+ }
54
+ //#endregion
55
+ export { EDGE_SECONDS, edgeHeaders, entryTags, groupTags, listTags, recordTags, siteTags, tagsOf };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hollware/nuxt",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Read Hollware from a Nuxt site, over a service binding or HTTP.",
5
5
  "homepage": "https://hollware.com",
6
6
  "bugs": {
@@ -29,7 +29,8 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@comark/vue": "0.7.0",
32
- "@hollware/client": "0.6.0",
32
+ "@hollware/client": "0.8.0",
33
+ "@hollware/core": "0.8.0",
33
34
  "@nuxt/kit": "4.6.1"
34
35
  },
35
36
  "devDependencies": {