@towa-digital/storyblok-nuxt-cv 0.1.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 ADDED
@@ -0,0 +1,176 @@
1
+ # @towa-digital/storyblok-nuxt-cv
2
+
3
+ Keeps **published Storyblok content fresh on long-running Nuxt servers** — no
4
+ redeploy after publish. Packages the `aichelin-multisite` 1.5.5 storage-bridge
5
+ fix as a drop-in Nuxt module.
6
+
7
+ Maintained by [TOWA](https://www.towa.digital). Design rationale and the full
8
+ bug analysis live in TOWA's internal `storyblok-cv-audit` playbook repo
9
+ (`docs/package-design.md`, `docs/problem.md`).
10
+
11
+ ## The problem (short version)
12
+
13
+ `storyblok-js-client` pins the space cache version (`cv`) in a process-global
14
+ map for the process lifetime, and Storyblok's CDN serves a **frozen snapshot**
15
+ for every URL warmed under that `cv` (7-day s-maxage). On client **v6** nothing
16
+ ever heals the pin — published content stays stale **until redeploy**; on **v5
17
+ and v7** the pin self-heals on the first cache-missing response, which presents
18
+ as unpredictable per-worker **flapping** after every publish. In production
19
+ builds the render client is a **separate bundled copy** of the client that
20
+ Nitro code can't reach, so a plain server-side `flushCache()` webhook does not
21
+ fix the render path.
22
+
23
+ ## What this module does
24
+
25
+ - **Nitro middleware** attaches the stored live `cv` to every page render's
26
+ `event.context`; an **app plugin** (compiled into the same bundle as the
27
+ render client) pins it via `useStoryblokApi().setCacheVersion(cv)` before
28
+ content is fetched, and forwards it to the browser through the payload.
29
+ - The stored `cv` is kept fresh by three composable strategies:
30
+ - **startup init** (on by default) — populates the store at boot,
31
+ - **publish webhook** (built-in route, or one line in your existing handler),
32
+ - **TTL stale-while-revalidate** — for serverless/edge and multi-instance.
33
+ - Works with `storyblok-js-client` **v5 → v7** and `@storyblok/nuxt` **5 → 9**:
34
+ the server side never imports the client (raw `cdn/spaces/me` fetch), the app
35
+ side only uses `useStoryblokApi`/`setCacheVersion`, which are stable across
36
+ all those majors. Keeps Storyblok's `cv`-keyed CDN caching intact (unlike
37
+ `cache: { cv: 'manual' }`).
38
+
39
+ ## Install
40
+
41
+ Requires `@storyblok/nuxt` (any major from 5 to 9) in the host app.
42
+
43
+ ```bash
44
+ yarn add @towa-digital/storyblok-nuxt-cv
45
+ ```
46
+
47
+ ```ts
48
+ // nuxt.config.ts
49
+ export default defineNuxtConfig({
50
+ modules: [
51
+ ['@storyblok/nuxt', { accessToken: process.env.STORYBLOK_TOKEN }],
52
+ '@towa-digital/storyblok-nuxt-cv',
53
+ ],
54
+ })
55
+ ```
56
+
57
+ That's it for a **single-process Node server** (DigitalOcean preset etc.):
58
+ token is auto-detected from the `@storyblok/nuxt` config, startup init and the
59
+ built-in webhook route are on by default.
60
+
61
+ Then create a **story publish webhook** in Storyblok
62
+ (Settings → Webhooks, story events) pointing at
63
+ `https://<site>/api/storyblokCvWebhook`, set a secret, and provide it as
64
+ `NUXT_STORYBLOK_CV_WEBHOOK_SECRET`.
65
+
66
+ ## Configuration
67
+
68
+ ```ts
69
+ export default defineNuxtConfig({
70
+ storyblokCv: {
71
+ token: undefined, // default: auto-detected from @storyblok/nuxt config; env NUXT_STORYBLOK_CV_TOKEN
72
+ region: 'eu', // eu | us | ap | ca | cn — or apiHost: 'https://...'
73
+ storage: 'storyblok-cv', // nitro storage mount name
74
+ endpoint: '/api/storyblokCv', // GET current cv; false to disable
75
+ webhook: true, // built-in route /api/storyblokCvWebhook; string = custom route; false = use refreshStoryblokCv() yourself
76
+ ttl: false, // e.g. 60_000 — background refresh when the stored cv is older (serverless/multi-instance)
77
+ onStartup: true, // populate the store once at boot
78
+ },
79
+ })
80
+ ```
81
+
82
+ ## Deployment-target matrix (pick your refresh strategy)
83
+
84
+ | Target | Storage | Strategy |
85
+ |---|---|---|
86
+ | Single Node process (DO app platform, plain node-server) | default (memory) | webhook (default setup) |
87
+ | PM2 **cluster** on one host (`instances: 'max'`) | mount `fs` driver (shared disk) | webhook |
88
+ | Multi-node / containers | mount Redis | webhook |
89
+ | Cloudflare / Vercel Edge / Netlify (isolates) | default | **`ttl` (e.g. 60_000)** — a webhook can't reach every isolate |
90
+
91
+ ```ts
92
+ // PM2 cluster example — the memory default is per worker, the webhook would
93
+ // only update the one worker that received it:
94
+ nitro: {
95
+ storage: { 'storyblok-cv': { driver: 'fs', base: './.data/storyblok-cv' } },
96
+ },
97
+ ```
98
+
99
+ If unsure, setting `ttl` **in addition to** the webhook is a safe belt-and-braces
100
+ default: staleness is then bounded by the TTL even if webhook delivery breaks.
101
+
102
+ ## Integrating with an existing webhook
103
+
104
+ Most TOWA projects already have a publish webhook (`update-redirects.post.ts`
105
+ and friends). Set `webhook: false` and add one line — `refreshStoryblokCv` is
106
+ auto-imported in server code:
107
+
108
+ ```ts
109
+ // server/api/update-redirects.post.ts (existing handler, after signature check)
110
+ await refreshStoryblokCv()
111
+ ```
112
+
113
+ ## Multi-tenant spaces: refresh on every publish (do not tenant-filter)
114
+
115
+ Refreshing on **every content publish in the space** is the right behavior even
116
+ for multi-tenant shared spaces where only one tenant's content changed — so the
117
+ default needs no configuration. Filtering out sibling tenants' publishes (as
118
+ aichelin 1.5.5 originally did) looks like it protects your CDN cache, but it
119
+ only protects URLs that were already warm: cold fetches get 301-corrected to
120
+ the latest `cv` and fill the cache under the new generation anyway, while your
121
+ pinned client keeps paying that miss+301 hop on every fetch of them until your
122
+ own next publish. It also serves stale resolved content if tenants share any
123
+ references or datasources. Adopting the sibling's `cv` instead costs one origin
124
+ fetch per URL identity, once.
125
+
126
+ For genuinely special cases (e.g. origin rate-limit pressure from extremely
127
+ frequent sibling publishes), the `storyblok-cv:webhook` nitro hook can veto a
128
+ refresh: handlers receive `{ payload, refresh }` and may set `refresh = false`.
129
+ Prefer throttling over hard filtering if you reach for this.
130
+
131
+ ## What this module does NOT solve
132
+
133
+ - **ISR / page-cache staleness**: a fresh `cv` doesn't invalidate HTML already
134
+ cached by `routeRules: { isr: true }` or a CDN page cache in front of the app.
135
+ - **URL-shape hygiene**: inconsistent trailing slashes in `cdn/stories/...`
136
+ paths create a second cache identity per story (flaky staleness). Normalize
137
+ in the app; see the audit playbook's `docs/remediation.md`.
138
+ - On **client ≥ 7.4.0** (check the lockfile — the option doesn't exist before
139
+ 7.4.0) you can alternatively set `cache: { cv: 'manual' }` and skip this
140
+ module — at the cost of losing Storyblok's `cv`-keyed CDN caching (every SSR
141
+ fetch hits origin). This module keeps CDN caching, and covers v5/v6/early-v7
142
+ where no such option exists.
143
+
144
+ ## Verifying an installation
145
+
146
+ Don't trust a single "looks fresh" check. Use the lab method from the audit
147
+ playbook (`docs/detection.md` Step 7): instrumented production build, publish
148
+ via **form mode** (not the visual editor), sample repeatedly, test the warmed
149
+ URL shape. Quick sanity check in production: `GET /api/storyblokCv` before and
150
+ after a publish — the value must change without a restart.
151
+
152
+ ## Development
153
+
154
+ ```bash
155
+ npm install
156
+ npm run dev:prepare # stub build + playground types
157
+ npm run dev # playground on localhost:3000
158
+ npm run lint # eslint (@nuxt/eslint-config, tooling + stylistic)
159
+ npm run test # vitest e2e against a fixture app + mocked Storyblok API
160
+ npm run test:types # vue-tsc, server and app contexts separately
161
+ npm run prepack # build dist/
162
+ npm run release # local: lint + test + build + changelogen + npm publish
163
+ ```
164
+
165
+ ## Releasing
166
+
167
+ CI and releases run on **Buddy** and are **managed in Buddy** (GUI/API), not
168
+ from a committed file — same as `@towa-digital/storyblok-nuxt-sitemap`. Two
169
+ pipelines: *Test* (runs on branch pushes) and *Release to npm* (triggered by
170
+ pushing a **tag** = version, publishes to public npm via
171
+ `npm version $BUDDY_RUN_TAG`).
172
+
173
+ Release flow: `npx changelogen --release --push` bumps the version + changelog
174
+ and pushes a tag; the tag push triggers the pipeline. Publish auth comes from a
175
+ Buddy **FILE variable** named `npmrc` (npm auth config) on the project or
176
+ workspace — `$npmrc` resolves to its path, copied to `.npmrc` before publish.
@@ -0,0 +1,59 @@
1
+ import * as _nuxt_schema from '@nuxt/schema';
2
+
3
+ interface ModuleOptions {
4
+ /** Disable the module entirely (no handlers, no plugin). */
5
+ enabled?: boolean;
6
+ /** Verbose logging during setup. */
7
+ debug?: boolean;
8
+ /**
9
+ * Storyblok access token used to read the live `cv` from `cdn/spaces/me`.
10
+ * Defaults to the token found in the host's `@storyblok/nuxt` config.
11
+ * Overridable at runtime via `NUXT_STORYBLOK_CV_TOKEN`.
12
+ */
13
+ token?: string;
14
+ /** Storyblok API region. Ignored when `apiHost` is set. */
15
+ region?: 'eu' | 'us' | 'ap' | 'ca' | 'cn';
16
+ /** Full API host override, e.g. for proxies. */
17
+ apiHost?: string;
18
+ /**
19
+ * Nitro storage mount the `cv` is stored under. The default (unmounted) is
20
+ * per-process memory — fine for a single Node process. For PM2 cluster mount
21
+ * an `fs` driver, for multi-node mount Redis, in the host's `nitro.storage`.
22
+ */
23
+ storage?: string;
24
+ /** GET endpoint exposing the current `cv` (client re-pins, debugging). `false` disables. */
25
+ endpoint?: string | false;
26
+ /**
27
+ * Built-in Storyblok publish webhook. `true` registers it at
28
+ * `/api/storyblokCvWebhook`, a string sets the route, `false` disables it —
29
+ * then call the auto-imported `refreshStoryblokCv()` from your own webhook
30
+ * handler instead. Requires `NUXT_STORYBLOK_CV_WEBHOOK_SECRET`.
31
+ */
32
+ webhook?: boolean | string;
33
+ /**
34
+ * Max age (ms) of the stored `cv` before a page render triggers a background
35
+ * refresh. Required on serverless/edge targets (Cloudflare, Vercel, Netlify)
36
+ * and multi-instance setups without shared storage, where a webhook cannot
37
+ * reach every isolate. `false` disables (webhook/startup only).
38
+ */
39
+ ttl?: number | false;
40
+ /** Populate the `cv` store once at boot (fire-and-forget). */
41
+ onStartup?: boolean;
42
+ }
43
+ declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
44
+
45
+ declare module '@nuxt/schema' {
46
+ interface RuntimeConfig {
47
+ storyblokCv: {
48
+ token: string;
49
+ apiHost: string;
50
+ storage: string;
51
+ ttl: number;
52
+ onStartup: boolean;
53
+ webhookSecret: string;
54
+ };
55
+ }
56
+ }
57
+
58
+ export { _default as default };
59
+ export type { ModuleOptions };
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@towa-digital/storyblok-nuxt-cv",
3
+ "configKey": "storyblokCv",
4
+ "compatibility": {
5
+ "nuxt": ">=3.10.0"
6
+ },
7
+ "version": "0.1.2",
8
+ "builder": {
9
+ "@nuxt/module-builder": "1.0.2",
10
+ "unbuild": "3.6.1"
11
+ }
12
+ }
@@ -0,0 +1,121 @@
1
+ import { defineNuxtModule, createResolver, useLogger, addServerHandler, addServerPlugin, addServerImports, addPlugin, addTypeTemplate } from '@nuxt/kit';
2
+ import { defu } from 'defu';
3
+
4
+ const API_HOSTS = {
5
+ eu: "https://api.storyblok.com",
6
+ us: "https://api-us.storyblok.com",
7
+ ap: "https://api-ap.storyblok.com",
8
+ ca: "https://api-ca.storyblok.com",
9
+ cn: "https://api.storyblokchina.cn"
10
+ };
11
+ const module$1 = defineNuxtModule({
12
+ meta: {
13
+ name: "@towa-digital/storyblok-nuxt-cv",
14
+ configKey: "storyblokCv",
15
+ compatibility: { nuxt: ">=3.10.0" }
16
+ },
17
+ defaults: {
18
+ enabled: true,
19
+ debug: false,
20
+ region: "eu",
21
+ storage: "storyblok-cv",
22
+ endpoint: "/api/storyblokCv",
23
+ webhook: true,
24
+ ttl: false,
25
+ onStartup: true
26
+ },
27
+ setup(options, nuxt) {
28
+ const resolver = createResolver(import.meta.url);
29
+ const logger = useLogger("storyblok-cv");
30
+ logger.level = options.debug || nuxt.options.debug ? 4 : 3;
31
+ if (options.enabled === false) {
32
+ logger.debug("The module is disabled, skipping setup.");
33
+ return;
34
+ }
35
+ const token = options.token || findStoryblokToken(nuxt);
36
+ if (!token) {
37
+ logger.warn(
38
+ "No Storyblok access token found. Set `storyblokCv.token`, configure `@storyblok/nuxt`, or provide NUXT_STORYBLOK_CV_TOKEN \u2014 the cv bridge is inert without it."
39
+ );
40
+ }
41
+ nuxt.options.runtimeConfig.storyblokCv = defu(
42
+ nuxt.options.runtimeConfig.storyblokCv,
43
+ {
44
+ token: token ?? "",
45
+ apiHost: options.apiHost || API_HOSTS[options.region] || API_HOSTS.eu,
46
+ storage: options.storage,
47
+ ttl: options.ttl === false ? 0 : options.ttl ?? 0,
48
+ onStartup: options.onStartup !== false,
49
+ webhookSecret: ""
50
+ }
51
+ );
52
+ addServerHandler({
53
+ middleware: true,
54
+ handler: resolver.resolve("./runtime/server/middleware/cv")
55
+ });
56
+ if (options.endpoint) {
57
+ addServerHandler({
58
+ route: options.endpoint,
59
+ method: "get",
60
+ handler: resolver.resolve("./runtime/server/api/cv.get")
61
+ });
62
+ }
63
+ if (options.webhook) {
64
+ addServerHandler({
65
+ route: typeof options.webhook === "string" ? options.webhook : "/api/storyblokCvWebhook",
66
+ method: "post",
67
+ handler: resolver.resolve("./runtime/server/api/webhook.post")
68
+ });
69
+ }
70
+ addServerPlugin(resolver.resolve("./runtime/server/plugins/init"));
71
+ const utils = resolver.resolve("./runtime/server/utils/cv");
72
+ addServerImports([
73
+ { name: "refreshStoryblokCv", from: utils },
74
+ { name: "readStoryblokCv", from: utils },
75
+ { name: "getLiveStoryblokCv", from: utils }
76
+ ]);
77
+ const hasStoryblokNuxt = nuxt.options.modules.some(
78
+ (m) => typeof m === "string" && m.includes("@storyblok/nuxt") || Array.isArray(m) && typeof m[0] === "string" && m[0].includes("@storyblok/nuxt")
79
+ );
80
+ if (!hasStoryblokNuxt) {
81
+ logger.warn(
82
+ "`@storyblok/nuxt` not found in the host modules \u2014 this module requires it (the render-client pin uses `useStoryblokApi`). Expect the build to fail until it is added."
83
+ );
84
+ }
85
+ addPlugin(resolver.resolve("./runtime/plugin"));
86
+ addTypeTemplate({
87
+ filename: "module/storyblok-nuxt-cv.d.ts",
88
+ getContents: () => `// Generated by @towa-digital/storyblok-nuxt-cv
89
+ declare module 'nitropack' {
90
+ interface NitroRuntimeHooks {
91
+ 'storyblok-cv:webhook': (context: {
92
+ payload: Record<string, unknown>
93
+ /** Set to false to skip the cv refresh for this webhook delivery. */
94
+ refresh: boolean
95
+ }) => void | Promise<void>
96
+ }
97
+ }
98
+ declare module 'h3' {
99
+ interface H3EventContext {
100
+ /** Current Storyblok cv, attached by the storyblok-nuxt-cv middleware. */
101
+ storyblokCv?: number
102
+ }
103
+ }
104
+ export {}
105
+ `
106
+ }, { nitro: true, nuxt: true });
107
+ }
108
+ });
109
+ function findStoryblokToken(nuxt) {
110
+ const topLevel = nuxt.options.storyblok?.accessToken;
111
+ if (topLevel) return topLevel;
112
+ for (const entry of nuxt.options.modules ?? []) {
113
+ if (Array.isArray(entry) && typeof entry[0] === "string" && entry[0].includes("@storyblok/nuxt") && entry[1] && typeof entry[1] === "object") {
114
+ const inline = entry[1].accessToken;
115
+ if (inline) return inline;
116
+ }
117
+ }
118
+ return void 0;
119
+ }
120
+
121
+ export { module$1 as default };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Pin the current Storyblok `cv` on the render client before any content is
3
+ * fetched. This plugin compiles into the same Vue bundle as the render client,
4
+ * so `setCacheVersion()` here reaches the bundled client copy that Nitro-side
5
+ * code can't (the two-copies trap).
6
+ *
7
+ * Server: the `cv` comes from `event.context` (set by the Nitro middleware from
8
+ * shared storage) and is forwarded to the browser via the payload (`useState`),
9
+ * so client-side navigation stays cache-consistent.
10
+ */
11
+ declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
12
+ export default _default;
@@ -0,0 +1,14 @@
1
+ import { defineNuxtPlugin, useRequestEvent, useState, useStoryblokApi } from "#imports";
2
+ export default defineNuxtPlugin((nuxtApp) => {
3
+ const cv = useState("storyblok-cv", () => 0);
4
+ if (import.meta.server) {
5
+ const fromContext = useRequestEvent()?.context.storyblokCv;
6
+ if (typeof fromContext === "number" && fromContext > 0) {
7
+ cv.value = fromContext;
8
+ }
9
+ }
10
+ nuxtApp.hook("app:created", () => {
11
+ if (!cv.value) return;
12
+ useStoryblokApi()?.setCacheVersion?.(cv.value);
13
+ });
14
+ });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Current `cv` from shared storage — for client-side re-pins in long-lived
3
+ * sessions and for checking what production currently believes.
4
+ */
5
+ declare const _default: any;
6
+ export default _default;
@@ -0,0 +1,6 @@
1
+ import { defineEventHandler } from "#imports";
2
+ import { readStoryblokCv } from "../utils/cv.js";
3
+ export default defineEventHandler(async () => {
4
+ const record = await readStoryblokCv();
5
+ return { cv: record?.cv ?? 0, fetchedAt: record?.fetchedAt ?? 0 };
6
+ });
@@ -0,0 +1,17 @@
1
+ export interface StoryblokCvWebhookContext {
2
+ payload: Record<string, unknown>;
3
+ /** Set to `false` in a `storyblok-cv:webhook` nitro hook to skip the refresh. */
4
+ refresh: boolean;
5
+ }
6
+ /**
7
+ * Built-in Storyblok publish webhook: verify the HMAC-SHA1 signature, give the
8
+ * host a veto via the `storyblok-cv:webhook` nitro hook, then refresh the
9
+ * stored `cv`. Refreshing on every content publish in the space is the right
10
+ * default even for multi-tenant shared spaces — filtering out sibling tenants
11
+ * only protects already-warm URLs and adds a standing miss+301 hop to every
12
+ * cold fetch (see README before vetoing).
13
+ *
14
+ * Idempotent, so Storyblok's retry-on-non-2xx behaviour is safe.
15
+ */
16
+ declare const _default: any;
17
+ export default _default;
@@ -0,0 +1,54 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createHmac, timingSafeEqual } from "node:crypto";
3
+ import {
4
+ createError,
5
+ defineEventHandler,
6
+ readRawBody,
7
+ setResponseStatus,
8
+ useNitroApp,
9
+ useRuntimeConfig
10
+ } from "#imports";
11
+ import { refreshStoryblokCv } from "../utils/cv.js";
12
+ const CONTENT_ACTIONS = ["published", "unpublished", "deleted", "moved"];
13
+ export default defineEventHandler(async (event) => {
14
+ const { webhookSecret } = useRuntimeConfig().storyblokCv;
15
+ if (!webhookSecret) {
16
+ console.error("[storyblok-cv] webhook called but NUXT_STORYBLOK_CV_WEBHOOK_SECRET is not set");
17
+ throw createError({ status: 500, message: "Webhook secret not configured" });
18
+ }
19
+ const signature = event.headers.get("webhook-signature");
20
+ if (!signature) {
21
+ throw createError({ status: 401, message: "Missing signature" });
22
+ }
23
+ const bodyRaw = await readRawBody(event, "utf-8");
24
+ if (!bodyRaw) {
25
+ throw createError({ status: 400, message: "Empty payload" });
26
+ }
27
+ const expected = createHmac("sha1", webhookSecret).update(bodyRaw).digest("hex");
28
+ if (!safeEqual(signature, expected)) {
29
+ throw createError({ status: 401, message: "Invalid signature" });
30
+ }
31
+ let payload;
32
+ try {
33
+ payload = JSON.parse(bodyRaw);
34
+ } catch {
35
+ throw createError({ status: 400, message: "Malformed JSON payload" });
36
+ }
37
+ const context = {
38
+ payload,
39
+ refresh: CONTENT_ACTIONS.includes(payload.action)
40
+ };
41
+ const callHook = useNitroApp().hooks.callHook;
42
+ await callHook("storyblok-cv:webhook", context);
43
+ if (!context.refresh) {
44
+ setResponseStatus(event, 204);
45
+ return null;
46
+ }
47
+ const cv = await refreshStoryblokCv();
48
+ return { ok: true, cv };
49
+ });
50
+ function safeEqual(a, b) {
51
+ const bufferA = Buffer.from(a);
52
+ const bufferB = Buffer.from(b);
53
+ return bufferA.length === bufferB.length && timingSafeEqual(bufferA, bufferB);
54
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Attach the stored `cv` to `event.context.storyblokCv` for every page render;
3
+ * the app plugin reads it from there (no HTTP self-fetch) and pins it on the
4
+ * render client before content is fetched.
5
+ *
6
+ * With `ttl` configured, an expired value also triggers a background refresh
7
+ * (stale-while-revalidate) — the freshness mechanism for serverless/edge
8
+ * targets and multi-instance setups a webhook cannot reliably reach.
9
+ */
10
+ declare const _default: any;
11
+ export default _default;
@@ -0,0 +1,21 @@
1
+ import { defineEventHandler, useRuntimeConfig } from "#imports";
2
+ import { readStoryblokCv, refreshStoryblokCv } from "../utils/cv.js";
3
+ export default defineEventHandler(async (event) => {
4
+ const path = event.path;
5
+ if (path.startsWith("/api/") || path.startsWith("/_") || path.startsWith("/__")) return;
6
+ const { ttl } = useRuntimeConfig().storyblokCv;
7
+ const record = await readStoryblokCv();
8
+ if (record?.cv) {
9
+ event.context.storyblokCv = record.cv;
10
+ }
11
+ if (ttl > 0 && (!record || Date.now() - record.fetchedAt > ttl)) {
12
+ const refresh = refreshStoryblokCv().catch((error) => {
13
+ console.error("[storyblok-cv] ttl refresh failed:", error);
14
+ return 0;
15
+ });
16
+ const waitUntil = event.waitUntil;
17
+ if (waitUntil) {
18
+ waitUntil.call(event, refresh);
19
+ }
20
+ }
21
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Populate the shared `cv` store once at boot so the first renders read a real
3
+ * value instead of nothing until the first publish webhook. Fire-and-forget so
4
+ * a slow or unreachable Storyblok can't block startup; until it lands, SSR
5
+ * simply self-pins the current `cv` (a fresh process has no stale pin).
6
+ */
7
+ declare const _default: any;
8
+ export default _default;
@@ -0,0 +1,9 @@
1
+ import { defineNitroPlugin, useRuntimeConfig } from "#imports";
2
+ import { refreshStoryblokCv } from "../utils/cv.js";
3
+ export default defineNitroPlugin(() => {
4
+ const { onStartup, token } = useRuntimeConfig().storyblokCv;
5
+ if (!onStartup || !token) return;
6
+ refreshStoryblokCv().catch((error) => {
7
+ console.error("[storyblok-cv] startup init failed:", error);
8
+ });
9
+ });
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../../playground/.nuxt/tsconfig.server.json",
3
+ "include": ["./**/*"]
4
+ }
@@ -0,0 +1,20 @@
1
+ export interface StoryblokCvRecord {
2
+ cv: number;
3
+ fetchedAt: number;
4
+ }
5
+ /**
6
+ * Live space `cv` straight from the API. `cdn/spaces/me` is the one endpoint
7
+ * storyblok-js-client never caches or cv-pins — and this raw fetch doesn't
8
+ * touch any client copy at all, so it works with client v5 through v7 alike.
9
+ */
10
+ export declare function getLiveStoryblokCv(): Promise<number>;
11
+ /** Stored `cv` record from the shared storage mount, or `null` before the first refresh. */
12
+ export declare function readStoryblokCv(): Promise<StoryblokCvRecord | null>;
13
+ /**
14
+ * Fetch the live `cv` and persist it to shared storage. Auto-imported in
15
+ * server code — call this from your own publish-webhook handler when the
16
+ * built-in webhook route is disabled. Concurrent calls are deduped.
17
+ *
18
+ * @returns the live `cv` (`0` if the API reported none)
19
+ */
20
+ export declare function refreshStoryblokCv(): Promise<number>;
@@ -0,0 +1,30 @@
1
+ import { $fetch } from "ofetch";
2
+ import { useRuntimeConfig, useStorage } from "#imports";
3
+ const KEY = "current";
4
+ const store = () => useStorage(useRuntimeConfig().storyblokCv.storage);
5
+ export async function getLiveStoryblokCv() {
6
+ const { token, apiHost } = useRuntimeConfig().storyblokCv;
7
+ if (!token) {
8
+ throw new Error("[storyblok-cv] no access token configured");
9
+ }
10
+ const response = await $fetch("/v2/cdn/spaces/me", {
11
+ baseURL: apiHost,
12
+ query: { token }
13
+ });
14
+ return response?.space?.version ?? 0;
15
+ }
16
+ export async function readStoryblokCv() {
17
+ return store().getItem(KEY);
18
+ }
19
+ let pending = null;
20
+ export function refreshStoryblokCv() {
21
+ pending ??= getLiveStoryblokCv().then(async (cv) => {
22
+ if (cv) {
23
+ await store().setItem(KEY, { cv, fetchedAt: Date.now() });
24
+ }
25
+ return cv;
26
+ }).finally(() => {
27
+ pending = null;
28
+ });
29
+ return pending;
30
+ }
@@ -0,0 +1,3 @@
1
+ export { default } from './module.mjs'
2
+
3
+ export { type ModuleOptions } from './module.mjs'
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@towa-digital/storyblok-nuxt-cv",
3
+ "version": "0.1.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
+ "repository": "github:towa-digital/storyblok-nuxt-cv",
6
+ "author": {
7
+ "name": "Matthias Frank",
8
+ "email": "matthias.frank@towa.at"
9
+ },
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/types.d.mts",
18
+ "import": "./dist/module.mjs"
19
+ }
20
+ },
21
+ "main": "./dist/module.mjs",
22
+ "types": "./dist/types.d.mts",
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "scripts": {
27
+ "prepack": "nuxt-module-build build",
28
+ "dev": "nuxi dev playground",
29
+ "dev:build": "nuxi build playground",
30
+ "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
31
+ "release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish",
32
+ "lint": "eslint .",
33
+ "test": "vitest run",
34
+ "test:watch": "vitest watch",
35
+ "test:types": "vue-tsc --noEmit -p src/runtime/server/tsconfig.json && vue-tsc --noEmit -p tsconfig.app.json"
36
+ },
37
+ "dependencies": {
38
+ "@nuxt/kit": "^3.10.0 || ^4.0.0",
39
+ "defu": "^6.1.4",
40
+ "ofetch": "^1.4.1"
41
+ },
42
+ "peerDependencies": {
43
+ "@storyblok/nuxt": ">=5.0.0"
44
+ },
45
+ "devDependencies": {
46
+ "@nuxt/eslint-config": "^1.4.0",
47
+ "@nuxt/module-builder": "^1.0.1",
48
+ "@nuxt/schema": "^3.17.5",
49
+ "@nuxt/test-utils": "^3.19.0",
50
+ "@storyblok/nuxt": "^9.0.0",
51
+ "@types/node": "latest",
52
+ "changelogen": "^0.6.1",
53
+ "eslint": "^9.29.0",
54
+ "nuxt": "^3.17.5",
55
+ "typescript": "^5.8.0",
56
+ "vitest": "^3.2.0",
57
+ "vue-tsc": "^2.2.10"
58
+ }
59
+ }