@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 +176 -0
- package/dist/module.d.mts +59 -0
- package/dist/module.json +12 -0
- package/dist/module.mjs +121 -0
- package/dist/runtime/plugin.d.ts +12 -0
- package/dist/runtime/plugin.js +14 -0
- package/dist/runtime/server/api/cv.get.d.ts +6 -0
- package/dist/runtime/server/api/cv.get.js +6 -0
- package/dist/runtime/server/api/webhook.post.d.ts +17 -0
- package/dist/runtime/server/api/webhook.post.js +54 -0
- package/dist/runtime/server/middleware/cv.d.ts +11 -0
- package/dist/runtime/server/middleware/cv.js +21 -0
- package/dist/runtime/server/plugins/init.d.ts +8 -0
- package/dist/runtime/server/plugins/init.js +9 -0
- package/dist/runtime/server/tsconfig.json +4 -0
- package/dist/runtime/server/utils/cv.d.ts +20 -0
- package/dist/runtime/server/utils/cv.js +30 -0
- package/dist/types.d.mts +3 -0
- package/package.json +59 -0
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 };
|
package/dist/module.json
ADDED
package/dist/module.mjs
ADDED
|
@@ -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,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,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
|
+
}
|
package/dist/types.d.mts
ADDED
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
|
+
}
|