@towa-digital/storyblok-nuxt-cv 0.5.0 → 0.5.2

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
@@ -87,9 +87,32 @@ export default defineNuxtConfig({
87
87
  |---|---|---|
88
88
  | Single Node process (DO app platform, plain node-server) | default (memory) | webhook (default setup) |
89
89
  | PM2 **cluster** on one host (`instances: 'max'`) | **`fsStore: true`** (or a manual `fs` mount) | webhook |
90
- | Multi-node / containers | mount Redis | webhook |
90
+ | Multi-node / containers | mount Redis (see below) | webhook |
91
91
  | Cloudflare / Vercel Edge / Netlify (isolates) | default | **`ttl` (seconds, e.g. `60`)** — a webhook can't reach every isolate |
92
92
 
93
+ ### Redis mounts: make the driver fail fast
94
+
95
+ A store read happens on every page render. Since 0.5.1 a read failure can't
96
+ break the render — the module falls back to the `cv` the process fetched itself
97
+ — but ioredis still retries **20 times per request** by default, so an
98
+ unreachable Redis adds seconds of latency per render before that fallback runs.
99
+ Configure it to give up immediately, and set `ttl` so each instance stays fresh
100
+ on its own if the store is gone:
101
+
102
+ ```ts
103
+ nitro: {
104
+ storage: {
105
+ 'storyblok-cv': {
106
+ driver: 'redis',
107
+ host: process.env.REDIS_HOST,
108
+ maxRetriesPerRequest: 1, // default 20 — fail fast, don't stall renders
109
+ enableOfflineQueue: false, // reject while disconnected instead of queueing
110
+ },
111
+ },
112
+ },
113
+ storyblokCv: { ttl: 60 },
114
+ ```
115
+
93
116
  On a **PM2 cluster** memory is per-worker, so a webhook only updates one worker.
94
117
  The simplest correct fix is **`fsStore`** — the module mounts an `fs` store at a
95
118
  path resolved **at runtime**, inside the app's own directory (owned by the
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.10.0"
6
6
  },
7
- "version": "0.5.0",
7
+ "version": "0.5.2",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
@@ -1,6 +1,6 @@
1
1
  import { defineEventHandler } from "#imports";
2
2
  import { readStoryblokCv } from "../utils/cv.js";
3
- export default defineEventHandler(async () => {
4
- const record = await readStoryblokCv();
3
+ export default defineEventHandler(async (event) => {
4
+ const record = await readStoryblokCv(event);
5
5
  return { cv: record?.cv ?? 0, fetchedAt: record?.fetchedAt ?? 0 };
6
6
  });
@@ -11,7 +11,7 @@ import {
11
11
  import { refreshStoryblokCv } from "../utils/cv.js";
12
12
  const CONTENT_ACTIONS = ["published", "unpublished", "deleted", "moved"];
13
13
  export default defineEventHandler(async (event) => {
14
- const { webhookSecret } = useRuntimeConfig().storyblokCv;
14
+ const { webhookSecret } = useRuntimeConfig(event).storyblokCv;
15
15
  if (!webhookSecret) {
16
16
  console.error("[storyblok-cv] webhook called but NUXT_STORYBLOK_CV_WEBHOOK_SECRET is not set");
17
17
  throw createError({ status: 500, message: "Webhook secret not configured" });
@@ -44,7 +44,7 @@ export default defineEventHandler(async (event) => {
44
44
  setResponseStatus(event, 204);
45
45
  return null;
46
46
  }
47
- const cv = await refreshStoryblokCv();
47
+ const cv = await refreshStoryblokCv(event);
48
48
  return { ok: true, cv };
49
49
  });
50
50
  function safeEqual(a, b) {
@@ -7,18 +7,18 @@ export default defineEventHandler(async (event) => {
7
7
  if (path.startsWith("/api/") || path.startsWith("/_") || path.startsWith("/__")) {
8
8
  if (Date.now() - lastMapSyncCheck > MAP_SYNC_INTERVAL) {
9
9
  lastMapSyncCheck = Date.now();
10
- readStoryblokCv().catch(() => {
10
+ readStoryblokCv(event).catch(() => {
11
11
  });
12
12
  }
13
13
  return;
14
14
  }
15
- const { ttl } = useRuntimeConfig().storyblokCv;
16
- const record = await readStoryblokCv();
15
+ const { ttl } = useRuntimeConfig(event).storyblokCv;
16
+ const record = await readStoryblokCv(event);
17
17
  if (record?.cv) {
18
18
  event.context.storyblokCv = record.cv;
19
19
  }
20
20
  if (ttl > 0 && (!record || Date.now() - record.fetchedAt > ttl)) {
21
- const refresh = refreshStoryblokCv().catch((error) => {
21
+ const refresh = refreshStoryblokCv(event).catch((error) => {
22
22
  console.error("[storyblok-cv] ttl refresh failed:", error);
23
23
  return 0;
24
24
  });
@@ -1,3 +1,4 @@
1
+ import type { H3Event } from 'h3';
1
2
  export interface StoryblokCvRecord {
2
3
  cv: number;
3
4
  fetchedAt: number;
@@ -7,15 +8,24 @@ export interface StoryblokCvRecord {
7
8
  * storyblok-js-client never caches or cv-pins — and this raw fetch doesn't
8
9
  * touch any client copy at all, so it works with client v5 through v7 alike.
9
10
  */
10
- export declare function getLiveStoryblokCv(): Promise<number>;
11
- /** Stored `cv` record from the storage mount, or `null` before the first refresh. */
12
- export declare function readStoryblokCv(): Promise<StoryblokCvRecord | null>;
13
- export declare function syncServerClientCv(cv: number): void;
11
+ export declare function getLiveStoryblokCv(event?: H3Event): Promise<number>;
12
+ /**
13
+ * Stored `cv` record from the storage mount, or `null` before the first
14
+ * refresh.
15
+ *
16
+ * Never throws: this runs on the render path (Nitro middleware), so a store
17
+ * that is down, misconfigured or not writable must not take page renders with
18
+ * it — it degrades to the in-process `cv` instead. Read failures are logged at
19
+ * most once a minute; per-render logging would flood the output.
20
+ */
21
+ export declare function readStoryblokCv(event?: H3Event): Promise<StoryblokCvRecord | null>;
22
+ export declare function syncServerClientCv(cv: number, event?: H3Event): void;
14
23
  /**
15
24
  * Fetch the live `cv` and persist it to storage. Auto-imported in server code —
16
25
  * call this from your own publish-webhook handler when the built-in webhook
17
- * route is disabled. Concurrent calls are deduped.
26
+ * route is disabled. Concurrent calls are deduped. Pass the request `event`
27
+ * when you have one, so runtime env vars apply on edge runtimes.
18
28
  *
19
29
  * @returns the live `cv` (`0` if the API reported none)
20
30
  */
21
- export declare function refreshStoryblokCv(): Promise<number>;
31
+ export declare function refreshStoryblokCv(event?: H3Event): Promise<number>;
@@ -1,10 +1,10 @@
1
1
  import { $fetch } from "ofetch";
2
2
  import { useRuntimeConfig, useStorage } from "#imports";
3
3
  const KEY = "current";
4
- const cfg = () => useRuntimeConfig().storyblokCv;
4
+ const cfg = (event) => useRuntimeConfig(event).storyblokCv;
5
5
  const store = () => useStorage(cfg().storage);
6
- export async function getLiveStoryblokCv() {
7
- const { token, endpoint } = cfg();
6
+ export async function getLiveStoryblokCv(event) {
7
+ const { token, endpoint } = cfg(event);
8
8
  if (!token) {
9
9
  throw new Error("[storyblok-cv] no access token configured");
10
10
  }
@@ -14,14 +14,29 @@ export async function getLiveStoryblokCv() {
14
14
  });
15
15
  return response?.space?.version ?? 0;
16
16
  }
17
- export async function readStoryblokCv() {
18
- const record = await store().getItem(KEY);
19
- if (record?.cv) syncServerClientCv(record.cv);
17
+ let inProcess = null;
18
+ let lastReadErrorAt = 0;
19
+ const READ_ERROR_LOG_INTERVAL = 6e4;
20
+ export async function readStoryblokCv(event) {
21
+ let record = null;
22
+ try {
23
+ record = await store().getItem(KEY);
24
+ } catch (error) {
25
+ if (Date.now() - lastReadErrorAt > READ_ERROR_LOG_INTERVAL) {
26
+ lastReadErrorAt = Date.now();
27
+ console.error(
28
+ "[storyblok-cv] storage read FAILED \u2014 falling back to the cv this process fetched itself; check the storage mount:",
29
+ error
30
+ );
31
+ }
32
+ }
33
+ if (inProcess && inProcess.cv > (record?.cv ?? 0)) record = inProcess;
34
+ if (record?.cv) syncServerClientCv(record.cv, event);
20
35
  return record;
21
36
  }
22
37
  let serverClient = null;
23
38
  let lastSyncedCv = 0;
24
- export function syncServerClientCv(cv) {
39
+ export function syncServerClientCv(cv, event) {
25
40
  if (!cv || cv === lastSyncedCv || serverClient === false) return;
26
41
  if (serverClient) {
27
42
  lastSyncedCv = cv;
@@ -30,7 +45,7 @@ export function syncServerClientCv(cv) {
30
45
  }
31
46
  import("storyblok-js-client").then((mod) => {
32
47
  const StoryblokClient = mod?.default;
33
- const { token } = cfg();
48
+ const { token } = cfg(event);
34
49
  if (!StoryblokClient || !token) {
35
50
  serverClient = false;
36
51
  return;
@@ -43,12 +58,14 @@ export function syncServerClientCv(cv) {
43
58
  });
44
59
  }
45
60
  let pending = null;
46
- export function refreshStoryblokCv() {
47
- pending ??= getLiveStoryblokCv().then(async (cv) => {
61
+ export function refreshStoryblokCv(event) {
62
+ pending ??= getLiveStoryblokCv(event).then(async (cv) => {
48
63
  if (cv) {
49
- syncServerClientCv(cv);
64
+ syncServerClientCv(cv, event);
65
+ const record = { cv, fetchedAt: Date.now() };
66
+ inProcess = record;
50
67
  try {
51
- await store().setItem(KEY, { cv, fetchedAt: Date.now() });
68
+ await store().setItem(KEY, record);
52
69
  } catch (error) {
53
70
  console.error(
54
71
  "[storyblok-cv] fetched cv but FAILED to persist it \u2014 check the storage mount is writable by the runtime user:",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@towa-digital/storyblok-nuxt-cv",
3
- "version": "0.5.0",
4
- "description": "Keeps Storyblok published content fresh on long-running Nuxt servers by bridging the live cache version (cv) to the render client \u2014 no redeploy after publish.",
3
+ "version": "0.5.2",
4
+ "description": "Keeps Storyblok published content fresh on long-running Nuxt servers by bridging the live cache version (cv) to the render client — no redeploy after publish.",
5
5
  "repository": "github:towa-digital/storyblok-nuxt-cv",
6
6
  "author": {
7
7
  "name": "Matthias Frank",