nuxt-compressed-cache 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +255 -2
- package/dist/module.d.mts +27 -0
- package/dist/module.json +12 -0
- package/dist/module.mjs +80 -0
- package/dist/runtime/compressed-cache.d.ts +101 -0
- package/dist/runtime/compressed-cache.js +288 -0
- package/dist/runtime/middleware.d.ts +3 -0
- package/dist/runtime/middleware.js +7 -0
- package/dist/runtime/server-utils.d.ts +35 -0
- package/dist/runtime/server-utils.js +19 -0
- package/dist/runtime/tasks/prune.d.ts +3 -0
- package/dist/runtime/tasks/prune.js +10 -0
- package/dist/types.d.mts +9 -0
- package/package.json +69 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Manthankumar Satani
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,256 @@
|
|
|
1
|
-
#
|
|
1
|
+
# nuxt-compressed-cache
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Serve Nuxt pages from pre-compressed cached copies.
|
|
4
|
+
|
|
5
|
+
Nitro's `swr` cache stores each response as a JSON entry and sends it uncompressed, so every hit parses the
|
|
6
|
+
entry, and the page is compressed again on every hit by whatever sits in front (a proxy or CDN), or goes out
|
|
7
|
+
uncompressed. This module stores rendered pages already compressed with brotli and gzip, then sends the stored
|
|
8
|
+
bytes as they are. A hit reads a small metadata entry and the compressed bytes. Compression runs once per
|
|
9
|
+
render instead of once per request.
|
|
10
|
+
|
|
11
|
+
- Stale-while-revalidate, configured per route like Nitro's `swr`
|
|
12
|
+
- Concurrent misses render once (per server process)
|
|
13
|
+
- Content negotiation by `Accept-Encoding` weights, including clients that accept no compression
|
|
14
|
+
- `ETag` and `304 Not Modified`
|
|
15
|
+
- Caches the `_payload.json` Nuxt fetches on client-side navigation, per build, via `isr: true` ([Payloads](#payloads))
|
|
16
|
+
- Pages from an older build are rendered again after a deploy
|
|
17
|
+
- Helpers to expire, remove and prune entries
|
|
18
|
+
|
|
19
|
+
> Requires Nuxt 5 (Nitro 3), deployed as a Node server (VPS, Docker, Coolify…). See [Deployment](#deployment).
|
|
20
|
+
|
|
21
|
+
## Why
|
|
22
|
+
|
|
23
|
+
When your own server renders and serves every request, each cache hit still costs work. Nitro's `swr` reads a
|
|
24
|
+
JSON entry, parses it and sends the page uncompressed. Then either your proxy compresses the same page again on
|
|
25
|
+
every hit, a CDN does it at the edge, or it goes out uncompressed. This module moves that work to render time:
|
|
26
|
+
|
|
27
|
+
- **Less work per hit.** A hit reads the stored bytes and sends them as they are. The page itself is never
|
|
28
|
+
parsed or compressed on a hit.
|
|
29
|
+
- **Smaller responses from your server.** Pages leave your server compressed, even when nothing in front of it
|
|
30
|
+
compresses. Compression is paid once per render, so a higher brotli quality costs nothing per hit.
|
|
31
|
+
- **Smaller storage.** Entries are stored compressed.
|
|
32
|
+
- **Client-side navigation is cached too.** Nuxt's `_payload.json` is cached per build, without also turning on
|
|
33
|
+
Nitro's cache.
|
|
34
|
+
- **Safe across deploys.** After a deploy, pages stored by the previous build render again before they are
|
|
35
|
+
sent.
|
|
36
|
+
|
|
37
|
+
### You might not need this
|
|
38
|
+
|
|
39
|
+
This is a niche module. Use Nitro's `swr` or `isr` instead, or nothing at all, if:
|
|
40
|
+
|
|
41
|
+
- you deploy to Vercel, Netlify, Cloudflare or another serverless or edge platform. They provide their own
|
|
42
|
+
caching and compression, and the module isn't built for them;
|
|
43
|
+
- a CDN in front of your server already caches your HTML;
|
|
44
|
+
- your pages can be prerendered, so there's nothing to render at request time;
|
|
45
|
+
- your traffic is low or your pages are cheap to render, so `swr` is fast enough;
|
|
46
|
+
- your pages differ per visitor by something other than a cookie (such as language or user agent), since every
|
|
47
|
+
visitor gets the same copy.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pnpm add nuxt-compressed-cache
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// nuxt.config.ts
|
|
57
|
+
export default defineNuxtConfig({
|
|
58
|
+
modules: ['nuxt-compressed-cache'],
|
|
59
|
+
routeRules: {
|
|
60
|
+
'/': { compressedCache: { maxAge: 60 * 60 } },
|
|
61
|
+
'/blog/**': { compressedCache: { maxAge: 60 * 60 * 24 } },
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Responses served by the cache carry an `x-cache` header: `HIT`, `STALE` or `MISS`. Responses it passes
|
|
67
|
+
through without storing (an error, a cookie, a bypassed request) don't.
|
|
68
|
+
|
|
69
|
+
The cache does nothing in development or while prerendering.
|
|
70
|
+
|
|
71
|
+
## Storage
|
|
72
|
+
|
|
73
|
+
By default, entries go to Nitro's `cache` storage mount. If that mount isn't backed by persistent storage in
|
|
74
|
+
production, entries are lost on every restart. To use a dedicated persistent mount:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
export default defineNuxtConfig({
|
|
78
|
+
compressedCache: {
|
|
79
|
+
// an unstorage driver, mounted as `compressed-cache`
|
|
80
|
+
storage: { driver: 'fs', base: './.data/compressed-cache' },
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
You can also pass the name of a mount you configured yourself, for example `storage: 'redis'`.
|
|
86
|
+
|
|
87
|
+
## Route rule
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
export default defineNuxtConfig({
|
|
91
|
+
routeRules: {
|
|
92
|
+
'/products/**': {
|
|
93
|
+
compressedCache: {
|
|
94
|
+
maxAge: 3600,
|
|
95
|
+
staleMaxAge: 86400,
|
|
96
|
+
allowQuery: ['page'],
|
|
97
|
+
bypassCookies: ['session'],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
| Option | Description |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `maxAge` | Seconds a copy is fresh. |
|
|
107
|
+
| `staleMaxAge` | Seconds a copy may still be served after `maxAge` while it re-renders in the background. Unset serves stale copies until they are replaced. `0` always waits for a fresh render. When set, entries also get a storage TTL of `maxAge + staleMaxAge`, on drivers that support one. |
|
|
108
|
+
| `allowQuery` | Query params that get their own entry. Other params are ignored for lookups, and a request carrying them is never stored. |
|
|
109
|
+
| `bypassCookies` | Requests carrying any of these cookies skip the cache, for example a session cookie. |
|
|
110
|
+
|
|
111
|
+
## Module options
|
|
112
|
+
|
|
113
|
+
| Option | Default | Description |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| `storage` | `'cache'` | Name of a Nitro storage mount, or unstorage driver options mounted as `compressed-cache`. |
|
|
116
|
+
| `prefix` | `'compressed-cache'` | Key prefix inside the storage mount. |
|
|
117
|
+
| `encodings` | `['br', 'gzip']` | Copies to store. Storing only `br` drops the gzip copy, and clients that don't accept brotli are then converted on the fly. Clients that accept no compression always get a copy decompressed on the fly. |
|
|
118
|
+
| `brotliQuality` | `6` | Brotli quality, used once per render. |
|
|
119
|
+
| `gzipLevel` | `6` | Gzip level, used once per render. |
|
|
120
|
+
| `statusHeader` | `'x-cache'` | Header that reports `HIT`, `STALE` or `MISS`. `false` omits it. |
|
|
121
|
+
| `invalidateOnBuild` | `true` | Tie entries to the build. After a deploy that keeps the storage, pages render again before they are sent, and payloads are kept per build (see [Payloads](#payloads)). |
|
|
122
|
+
| `cleanup` | `false` | Enable Nitro's scheduled pruning task. `true` runs daily at 04:00 and removes expired entries (see [Server helpers](#server-helpers)). An object can override `schedule` (cron) and set `olderThan` (seconds) to remove by age instead. |
|
|
123
|
+
|
|
124
|
+
## Server helpers
|
|
125
|
+
|
|
126
|
+
These are auto-imported in server code. A `path` also covers that page's `_payload.json`.
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
// mark entries stale: each is served stale while it re-renders (re-rendered first when `staleMaxAge` is 0)
|
|
130
|
+
await expireCompressedCache({ path: '/blog/my-post' })
|
|
131
|
+
await expireCompressedCache({ pathPrefix: '/blog/' })
|
|
132
|
+
|
|
133
|
+
// delete entries: the next request renders fresh
|
|
134
|
+
await removeCompressedCache({ path: '/blog/my-post' })
|
|
135
|
+
|
|
136
|
+
// delete expired entries: past `maxAge` (plus `staleMaxAge` when set) of their page's current route rule,
|
|
137
|
+
// and entries whose page no longer has a `compressedCache` rule
|
|
138
|
+
await pruneCompressedCache()
|
|
139
|
+
|
|
140
|
+
// or delete entries rendered more than `olderThan` seconds ago, whatever their rule
|
|
141
|
+
await pruneCompressedCache({ olderThan: 7 * 24 * 60 * 60 })
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Without `staleMaxAge`, a stale copy is served until a visit replaces it, so pruning only removes pages nobody
|
|
145
|
+
has visited since they expired. Their next visitor waits for a render instead of getting the stale copy.
|
|
146
|
+
|
|
147
|
+
Storage drivers without a TTL (such as `fs`) never drop entries on their own. Enable the built-in cleanup
|
|
148
|
+
task with `compressedCache: { cleanup: true }`, which runs `pruneCompressedCache()` daily at 04:00, or
|
|
149
|
+
customize it:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
// nuxt.config.ts
|
|
153
|
+
export default defineNuxtConfig({
|
|
154
|
+
compressedCache: {
|
|
155
|
+
cleanup: {
|
|
156
|
+
schedule: '0 */6 * * *',
|
|
157
|
+
// remove by age instead of expiry
|
|
158
|
+
olderThan: 7 * 24 * 60 * 60,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Cleanup is disabled by default. When enabled, the module enables Nitro tasks, registers
|
|
165
|
+
`compressed-cache:prune` and adds its schedule alongside existing tasks. It uses the configured storage and
|
|
166
|
+
prefix, removing each pruned entry's metadata and compressed copies. Schedules use the server's timezone.
|
|
167
|
+
|
|
168
|
+
The built-in task only prunes. For more control, such as clearing the cache when the disk is almost full,
|
|
169
|
+
leave `cleanup` off and call `pruneCompressedCache` from your own task:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
// server/tasks/compressed-cache/prune.ts
|
|
173
|
+
import { defineTask } from 'nitro/task'
|
|
174
|
+
|
|
175
|
+
export default defineTask({
|
|
176
|
+
meta: { name: 'compressed-cache:prune' },
|
|
177
|
+
async run() {
|
|
178
|
+
return { result: await pruneCompressedCache() }
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
// nuxt.config.ts
|
|
185
|
+
export default defineNuxtConfig({
|
|
186
|
+
nitro: {
|
|
187
|
+
experimental: { tasks: true },
|
|
188
|
+
scheduledTasks: { '0 4 * * *': 'compressed-cache:prune' },
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## What gets cached
|
|
194
|
+
|
|
195
|
+
A response is stored only when all of these are true:
|
|
196
|
+
|
|
197
|
+
- it is a `GET` with status `200`;
|
|
198
|
+
- it is HTML or JSON;
|
|
199
|
+
- it has no `Set-Cookie` header;
|
|
200
|
+
- its `Cache-Control` is not `no-store` or `private`;
|
|
201
|
+
- it isn't already encoded (`Content-Encoding`).
|
|
202
|
+
|
|
203
|
+
When a background re-render can't be stored (a `404`, a redirect, `private`, `no-store`, a cookie), the stored
|
|
204
|
+
copy is removed. A server error (`5xx`) keeps it, so a short outage doesn't empty the cache.
|
|
205
|
+
|
|
206
|
+
> [!WARNING]
|
|
207
|
+
> There are no per-header variants. Every visitor gets the same stored copy, whatever their `Accept-Language`,
|
|
208
|
+
> user agent or cookies. Don't cache pages that render differently per visitor, or list the cookie that changes
|
|
209
|
+
> them in `bypassCookies`.
|
|
210
|
+
|
|
211
|
+
## Payloads
|
|
212
|
+
|
|
213
|
+
A direct visit gets the cached HTML, with the page's data inlined. On client-side navigation, Nuxt fetches
|
|
214
|
+
`<page>/_payload.json?_b=<build id>` instead, which is cached the same way.
|
|
215
|
+
|
|
216
|
+
At runtime, Nuxt only serves `_payload.json` on routes it sees as cached, through an `isr` or `cache` rule.
|
|
217
|
+
`cache` would also turn on Nitro's own cache, so on Node server presets the module adds `isr: true` to every
|
|
218
|
+
`compressedCache` route instead, which those presets ignore. A route that sets `isr` itself keeps its value.
|
|
219
|
+
|
|
220
|
+
Payloads are kept per build, like the `_b` param Nuxt adds to them:
|
|
221
|
+
|
|
222
|
+
| Request | Served from | Stored |
|
|
223
|
+
| --- | --- | --- |
|
|
224
|
+
| `_b` is the running build, or no `_b` | The running build's entry | Yes |
|
|
225
|
+
| `_b` is an older build | That build's entry while it is fresh, so an open tab keeps the data its code expects | Never |
|
|
226
|
+
| Any other `_b` | Rendered without the cache | Never |
|
|
227
|
+
|
|
228
|
+
## Deployment
|
|
229
|
+
|
|
230
|
+
The module is built for long-running Node servers: a VPS, Docker or a platform like Coolify, with a `node-*`,
|
|
231
|
+
`bun` or `deno-server` preset. It relies on:
|
|
232
|
+
|
|
233
|
+
- `node:zlib` and `node:crypto`;
|
|
234
|
+
- a long-running process, which merges concurrent renders in memory (per process) and re-renders in the
|
|
235
|
+
background after responding;
|
|
236
|
+
- persistent storage for the entries.
|
|
237
|
+
|
|
238
|
+
On other presets (serverless and edge platforms such as Vercel, Netlify and Cloudflare), the module doesn't add
|
|
239
|
+
`isr`, because there it can mean something else (on Vercel it turns on Vercel's own cache). It logs a warning
|
|
240
|
+
instead, and pages there get no `_payload.json` for client-side navigation unless you set `isr` or `cache`
|
|
241
|
+
yourself.
|
|
242
|
+
|
|
243
|
+
## Development
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
pnpm install
|
|
247
|
+
pnpm dev:prepare
|
|
248
|
+
pnpm dev # playground
|
|
249
|
+
pnpm test # unit and e2e tests
|
|
250
|
+
pnpm test:types
|
|
251
|
+
pnpm lint
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## License
|
|
255
|
+
|
|
256
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as _nuxt_schema from '@nuxt/schema';
|
|
2
|
+
import { CompressedCacheConfig } from '../dist/runtime/compressed-cache.js';
|
|
3
|
+
|
|
4
|
+
interface CompressedCacheModuleOptions extends Omit<CompressedCacheConfig, 'storage' | 'version'> {
|
|
5
|
+
/** Name of an existing Nitro storage mount, or unstorage driver options mounted as `compressed-cache`. */
|
|
6
|
+
storage?: string | {
|
|
7
|
+
driver: string;
|
|
8
|
+
[option: string]: unknown;
|
|
9
|
+
};
|
|
10
|
+
/** Treat entries from a previous build as stale, so they re-render after a deploy that keeps the storage. */
|
|
11
|
+
invalidateOnBuild?: boolean;
|
|
12
|
+
/** Enable scheduled pruning, daily at 04:00 by default, of entries past their route rule's `maxAge` and `staleMaxAge`. */
|
|
13
|
+
cleanup?: boolean | {
|
|
14
|
+
/** Cron expression for Nitro's task runner. */
|
|
15
|
+
schedule?: string;
|
|
16
|
+
/** Remove entries rendered more than this many seconds ago, instead of when they expire. */
|
|
17
|
+
olderThan?: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Serves `compressedCache` route rules from pre-compressed copies, configured like Nitro's `swr`:
|
|
22
|
+
* `routeRules: { '/blog/**': { compressedCache: { maxAge: 3600 } } }`.
|
|
23
|
+
*/
|
|
24
|
+
declare const _default: _nuxt_schema.NuxtModule<CompressedCacheModuleOptions, CompressedCacheModuleOptions, false>;
|
|
25
|
+
|
|
26
|
+
export { _default as default };
|
|
27
|
+
export type { CompressedCacheModuleOptions };
|
package/dist/module.json
ADDED
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { defineNuxtModule, createResolver, useLogger, addServerHandler, addServerImports, addTypeTemplate } from 'nuxt/kit';
|
|
2
|
+
|
|
3
|
+
const SERVER_PRESET = /^(?:node-|bun$|deno-server$|nitro-)/;
|
|
4
|
+
const module$1 = defineNuxtModule({
|
|
5
|
+
meta: {
|
|
6
|
+
name: "nuxt-compressed-cache",
|
|
7
|
+
configKey: "compressedCache",
|
|
8
|
+
compatibility: { nuxt: ">=5.0.0-0" }
|
|
9
|
+
},
|
|
10
|
+
defaults: {
|
|
11
|
+
storage: "cache",
|
|
12
|
+
prefix: "compressed-cache",
|
|
13
|
+
encodings: ["br", "gzip"],
|
|
14
|
+
brotliQuality: 6,
|
|
15
|
+
gzipLevel: 6,
|
|
16
|
+
statusHeader: "x-cache",
|
|
17
|
+
invalidateOnBuild: true,
|
|
18
|
+
cleanup: false
|
|
19
|
+
},
|
|
20
|
+
setup({ storage, invalidateOnBuild, cleanup, ...options }, nuxt) {
|
|
21
|
+
const resolver = createResolver(import.meta.url);
|
|
22
|
+
const { schedule = "0 4 * * *", olderThan } = typeof cleanup === "object" ? cleanup : {};
|
|
23
|
+
const mount = typeof storage === "object" ? "compressed-cache" : storage ?? "cache";
|
|
24
|
+
if (typeof storage === "object") {
|
|
25
|
+
nuxt.options.nitro.storage = { ...nuxt.options.nitro.storage, [mount]: storage };
|
|
26
|
+
}
|
|
27
|
+
nuxt.options.runtimeConfig.compressedCache = {
|
|
28
|
+
...options,
|
|
29
|
+
storage: mount,
|
|
30
|
+
version: invalidateOnBuild ? nuxt.options.buildId : void 0,
|
|
31
|
+
cleanup: cleanup ? { olderThan } : false
|
|
32
|
+
};
|
|
33
|
+
if (cleanup) {
|
|
34
|
+
const nitro = nuxt.options.nitro;
|
|
35
|
+
nitro.experimental ||= {};
|
|
36
|
+
nitro.experimental.tasks = true;
|
|
37
|
+
nitro.tasks ||= {};
|
|
38
|
+
nitro.tasks["compressed-cache:prune"] = { handler: resolver.resolve("./runtime/tasks/prune") };
|
|
39
|
+
nitro.scheduledTasks ||= {};
|
|
40
|
+
const scheduled = nitro.scheduledTasks[schedule];
|
|
41
|
+
nitro.scheduledTasks[schedule] = [.../* @__PURE__ */ new Set([...Array.isArray(scheduled) ? scheduled : scheduled ? [scheduled] : [], "compressed-cache:prune"])];
|
|
42
|
+
}
|
|
43
|
+
nuxt.hook("nitro:init", async (nitro) => {
|
|
44
|
+
const cached = Object.entries(nitro.options.routeRules).filter(([, rules]) => rules.compressedCache && rules.isr === void 0);
|
|
45
|
+
if (!cached.length) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (!SERVER_PRESET.test(nitro.options.preset)) {
|
|
49
|
+
useLogger("nuxt-compressed-cache").warn(`Built for Node servers, the \`${nitro.options.preset}\` preset gets no \`_payload.json\` for client navigation.`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
await nitro.updateConfig({
|
|
53
|
+
routeRules: { ...nitro.options.routeRules, ...Object.fromEntries(cached.map(([route, rules]) => [route, { ...rules, isr: true }])) }
|
|
54
|
+
});
|
|
55
|
+
nitro.routing.sync();
|
|
56
|
+
});
|
|
57
|
+
addServerHandler({ middleware: true, handler: resolver.resolve("./runtime/middleware") });
|
|
58
|
+
addServerImports(
|
|
59
|
+
["expireCompressedCache", "removeCompressedCache", "pruneCompressedCache"].map((name) => ({ name, from: resolver.resolve("./runtime/server-utils") }))
|
|
60
|
+
);
|
|
61
|
+
addTypeTemplate({
|
|
62
|
+
filename: "types/compressed-cache.d.ts",
|
|
63
|
+
getContents: () => `import type { CompressedCacheRule } from ${JSON.stringify(resolver.resolve("./runtime/compressed-cache"))}
|
|
64
|
+
|
|
65
|
+
declare module 'nitro/types' {
|
|
66
|
+
interface RouteRules {
|
|
67
|
+
compressedCache?: CompressedCacheRule
|
|
68
|
+
}
|
|
69
|
+
interface RouteRuleConfig {
|
|
70
|
+
/** Serve this route from pre-compressed copies, re-rendered in the background after \`maxAge\` seconds. */
|
|
71
|
+
compressedCache?: CompressedCacheRule
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export {}
|
|
75
|
+
`
|
|
76
|
+
}, { nitro: true, nuxt: true, node: true, shared: true });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export { module$1 as default };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compressed cache that stores rendered HTML and payloads pre-compressed (brotli and/or gzip) and serves the bytes as-is,
|
|
3
|
+
* so a hit costs a small read instead of a JSON parse, and a render pays compression once instead of per request.
|
|
4
|
+
* It follows Nitro's route cache rules (stale-while-revalidate, 200 only, no cookies) without header or cookie variants.
|
|
5
|
+
*/
|
|
6
|
+
export type Encoding = 'br' | 'gzip';
|
|
7
|
+
/** Per route settings, set through the `compressedCache` route rule. */
|
|
8
|
+
export interface CompressedCacheRule {
|
|
9
|
+
/** Seconds a copy is fresh. */
|
|
10
|
+
maxAge: number;
|
|
11
|
+
/**
|
|
12
|
+
* Seconds a copy may still be served while it re-renders in the background, after `maxAge`.
|
|
13
|
+
* Unset serves stale copies until replaced, `0` always waits for a fresh render.
|
|
14
|
+
* When set, entries also get a storage TTL of `maxAge + staleMaxAge`.
|
|
15
|
+
*/
|
|
16
|
+
staleMaxAge?: number;
|
|
17
|
+
/** Query params that get their own entry, any other param is ignored for lookups and never stored. */
|
|
18
|
+
allowQuery?: string[];
|
|
19
|
+
/** Requests carrying any of these cookies skip the cache, for example a session cookie. */
|
|
20
|
+
bypassCookies?: string[];
|
|
21
|
+
}
|
|
22
|
+
export interface CompressedCacheOptions extends CompressedCacheConfig {
|
|
23
|
+
/** The rule for a page pathname, or `undefined` to leave the request alone. A payload is looked up by its page. */
|
|
24
|
+
rule: ({ pathname }: {
|
|
25
|
+
pathname: string;
|
|
26
|
+
}) => CompressedCacheRule | undefined;
|
|
27
|
+
}
|
|
28
|
+
/** Settings shared by every cached route, the module passes these through runtime config. */
|
|
29
|
+
export interface CompressedCacheConfig {
|
|
30
|
+
/** Storage mount for entries. */
|
|
31
|
+
storage?: string;
|
|
32
|
+
/** Key prefix inside the storage mount. */
|
|
33
|
+
prefix?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Compressed copies to store. Both serve every client as-is, brotli only halves storage
|
|
36
|
+
* and converts the rare gzip-only request on the fly.
|
|
37
|
+
*/
|
|
38
|
+
encodings?: Encoding[];
|
|
39
|
+
/** Brotli quality used once per render. */
|
|
40
|
+
brotliQuality?: number;
|
|
41
|
+
/** Gzip level used once per render. */
|
|
42
|
+
gzipLevel?: number;
|
|
43
|
+
/** Response header that reports `HIT`, `STALE` or `MISS`, `false` to omit it. */
|
|
44
|
+
statusHeader?: string | false;
|
|
45
|
+
/**
|
|
46
|
+
* The build id. A page entry stored by another build is rendered again before it is sent,
|
|
47
|
+
* and payloads are kept per build like the `_b` param Nuxt adds to them.
|
|
48
|
+
*/
|
|
49
|
+
version?: string;
|
|
50
|
+
}
|
|
51
|
+
/** Where entries live, also what the invalidation helpers need. */
|
|
52
|
+
export interface CompressedCacheLocation {
|
|
53
|
+
storage?: string;
|
|
54
|
+
prefix?: string;
|
|
55
|
+
}
|
|
56
|
+
export declare const DEFAULT_STORAGE = "cache";
|
|
57
|
+
export declare const DEFAULT_PREFIX = "compressed-cache";
|
|
58
|
+
/**
|
|
59
|
+
* Picks the available encoding the client weights highest (`q`, default 1, `*` for unlisted ones),
|
|
60
|
+
* brotli on a tie, and never one the client did not accept.
|
|
61
|
+
*
|
|
62
|
+
* @returns The encoding to send, or `undefined` when the client accepts none of them.
|
|
63
|
+
*/
|
|
64
|
+
export declare function negotiateEncoding(acceptEncoding: string | null, available?: Encoding[]): Encoding | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* Marks cached copies stale, each is served once more while it re-renders in the background.
|
|
67
|
+
* Pass `path` for one page (all its allowed query variants and its payload) or `pathPrefix` for every page under it.
|
|
68
|
+
*
|
|
69
|
+
* @returns How many entries were expired.
|
|
70
|
+
*/
|
|
71
|
+
export declare function expireCompressedCache(target: CompressedCacheLocation & {
|
|
72
|
+
path: string;
|
|
73
|
+
} | CompressedCacheLocation & {
|
|
74
|
+
pathPrefix: string;
|
|
75
|
+
}): Promise<number>;
|
|
76
|
+
/**
|
|
77
|
+
* Deletes cached copies, the next request renders fresh.
|
|
78
|
+
* Pass `path` for one page (all its allowed query variants and its payload) or `pathPrefix` for every page under it.
|
|
79
|
+
*
|
|
80
|
+
* @returns How many entries were removed.
|
|
81
|
+
*/
|
|
82
|
+
export declare function removeCompressedCache(target: CompressedCacheLocation & {
|
|
83
|
+
path: string;
|
|
84
|
+
} | CompressedCacheLocation & {
|
|
85
|
+
pathPrefix: string;
|
|
86
|
+
}): Promise<number>;
|
|
87
|
+
/**
|
|
88
|
+
* Deletes entries past the `maxAge` (plus `staleMaxAge` when set) of their page's current route rule,
|
|
89
|
+
* and entries whose page no longer has a rule, or, with `olderThan`, entries rendered more than that many
|
|
90
|
+
* seconds ago. Judged by each entry's own render time, so it works on every storage driver,
|
|
91
|
+
* including ones without file times (memory, redis, KV). Meant for a scheduled Nitro task.
|
|
92
|
+
*
|
|
93
|
+
* @returns How many entries were removed.
|
|
94
|
+
*/
|
|
95
|
+
export declare function pruneCompressedCache({ storage, prefix, ...by }: CompressedCacheLocation & ({
|
|
96
|
+
olderThan: number;
|
|
97
|
+
} | {
|
|
98
|
+
rule: CompressedCacheOptions['rule'];
|
|
99
|
+
})): Promise<number>;
|
|
100
|
+
/** Creates the compressed cache middleware. */
|
|
101
|
+
export declare function defineCompressedCache(options: CompressedCacheOptions): import("h3").Middleware;
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { brotliCompress, brotliCompressSync, brotliDecompressSync, constants, gunzipSync, gzip, gzipSync } from "node:zlib";
|
|
4
|
+
import { serverFetch } from "nitro/app";
|
|
5
|
+
import { defineMiddleware, toResponse } from "nitro/h3";
|
|
6
|
+
import { useStorage } from "nitro/storage";
|
|
7
|
+
const brotliAsync = promisify(brotliCompress);
|
|
8
|
+
const gzipAsync = promisify(gzip);
|
|
9
|
+
export const DEFAULT_STORAGE = "cache";
|
|
10
|
+
export const DEFAULT_PREFIX = "compressed-cache";
|
|
11
|
+
const ENCODINGS = ["br", "gzip"];
|
|
12
|
+
const ENCODING_FILE = { br: "br", gzip: "gz" };
|
|
13
|
+
const SKIPPED_HEADERS = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding", "set-cookie", "etag", "vary"]);
|
|
14
|
+
const CACHEABLE_TYPE = /^(?:text\/html|application\/json)\b/i;
|
|
15
|
+
const REVALIDATE_HEADER = "x-compressed-cache-revalidate";
|
|
16
|
+
const PAYLOAD_SUFFIX = "/_payload.json";
|
|
17
|
+
const BUILD_PARAM = "_b";
|
|
18
|
+
const REVALIDATE_TOKEN = randomUUID();
|
|
19
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
20
|
+
export function negotiateEncoding(acceptEncoding, available = ENCODINGS) {
|
|
21
|
+
const weights = /* @__PURE__ */ new Map();
|
|
22
|
+
for (const part of (acceptEncoding ?? "").toLowerCase().split(",")) {
|
|
23
|
+
const [name, ...params] = part.split(";").map((value) => value.trim());
|
|
24
|
+
const quality = params.find((param) => param.startsWith("q="));
|
|
25
|
+
if (name) {
|
|
26
|
+
weights.set(name, quality ? Number(quality.slice(2)) || 0 : 1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
let best;
|
|
30
|
+
let bestWeight = 0;
|
|
31
|
+
for (const encoding of ENCODINGS) {
|
|
32
|
+
const weight = weights.get(encoding) ?? weights.get("*") ?? 0;
|
|
33
|
+
if (available.includes(encoding) && weight > bestWeight) {
|
|
34
|
+
best = encoding;
|
|
35
|
+
bestWeight = weight;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return best;
|
|
39
|
+
}
|
|
40
|
+
function isCacheable(response) {
|
|
41
|
+
const cacheControl = response.headers.get("cache-control") ?? "";
|
|
42
|
+
const encoding = response.headers.get("content-encoding");
|
|
43
|
+
return response.status === 200 && !response.headers.has("set-cookie") && !/\b(?:no-store|private)\b/i.test(cacheControl) && (!encoding || encoding === "identity") && CACHEABLE_TYPE.test(response.headers.get("content-type") ?? "");
|
|
44
|
+
}
|
|
45
|
+
function hash(value) {
|
|
46
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
47
|
+
}
|
|
48
|
+
function pathKey({ prefix, pathname }) {
|
|
49
|
+
return `${prefix}:${hash(pathname)}`;
|
|
50
|
+
}
|
|
51
|
+
function entryKey({ prefix, pathname, query }) {
|
|
52
|
+
return `${pathKey({ prefix, pathname })}:${query ? hash(query) : "default"}`;
|
|
53
|
+
}
|
|
54
|
+
function normalizeQuery({ search, allowQuery = [], payload, version }) {
|
|
55
|
+
const params = [...new URLSearchParams(search)];
|
|
56
|
+
const keyed = payload ? params.filter(([name]) => name !== BUILD_PARAM) : params;
|
|
57
|
+
const allowed = keyed.filter(([name]) => allowQuery.includes(name)).sort(([a], [b]) => a.localeCompare(b));
|
|
58
|
+
const extra = allowed.length !== keyed.length;
|
|
59
|
+
if (!payload || !version) {
|
|
60
|
+
return { query: new URLSearchParams(allowed).toString(), extra, currentBuild: true };
|
|
61
|
+
}
|
|
62
|
+
const build = new URLSearchParams(search).get(BUILD_PARAM) ?? version;
|
|
63
|
+
return { query: new URLSearchParams([...allowed, [BUILD_PARAM, build]]).toString(), extra, currentBuild: build === version };
|
|
64
|
+
}
|
|
65
|
+
function pagePathname(path) {
|
|
66
|
+
const pathname = path.split("?")[0];
|
|
67
|
+
return pathname.endsWith(PAYLOAD_SUFFIX) ? pathname.slice(0, -PAYLOAD_SUFFIX.length) || "/" : pathname;
|
|
68
|
+
}
|
|
69
|
+
function payloadPath(path) {
|
|
70
|
+
return path.replace(/\/$/, "") + PAYLOAD_SUFFIX;
|
|
71
|
+
}
|
|
72
|
+
function hasCookie({ cookieHeader, names }) {
|
|
73
|
+
const present = (cookieHeader ?? "").split(";").map((cookie) => cookie.split("=")[0].trim());
|
|
74
|
+
return names.some((name) => present.includes(name));
|
|
75
|
+
}
|
|
76
|
+
function matchesEtag({ ifNoneMatch, etag }) {
|
|
77
|
+
const opaque = etag.replace(/^W\//, "");
|
|
78
|
+
return (ifNoneMatch ?? "").split(",").some((tag) => {
|
|
79
|
+
const value = tag.trim();
|
|
80
|
+
return value === "*" || value.replace(/^W\//, "") === opaque;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
async function removeEntry({ storage, entry }) {
|
|
84
|
+
const store = useStorage(storage);
|
|
85
|
+
await Promise.all(["meta", ...Object.values(ENCODING_FILE)].map((file) => store.removeItem(`${entry}:${file}`)));
|
|
86
|
+
}
|
|
87
|
+
async function compressStored({ encoding, raw, config }) {
|
|
88
|
+
return encoding === "br" ? brotliAsync(raw, { params: { [constants.BROTLI_PARAM_QUALITY]: config.brotliQuality ?? 6, [constants.BROTLI_PARAM_SIZE_HINT]: raw.byteLength } }) : gzipAsync(raw, { level: config.gzipLevel ?? 6 });
|
|
89
|
+
}
|
|
90
|
+
function convert({ body, from, to }) {
|
|
91
|
+
const raw = from === "br" ? brotliDecompressSync(body) : gunzipSync(body);
|
|
92
|
+
if (to === "br") {
|
|
93
|
+
return brotliCompressSync(raw, { params: { [constants.BROTLI_PARAM_QUALITY]: 1 } });
|
|
94
|
+
}
|
|
95
|
+
return to === "gzip" ? gzipSync(raw, { level: 1 }) : raw;
|
|
96
|
+
}
|
|
97
|
+
async function readPage({ storage, key, encoding }) {
|
|
98
|
+
const store = useStorage(storage);
|
|
99
|
+
const meta = await store.getItem(`${key}:meta`);
|
|
100
|
+
if (!meta) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const bytes = await store.getItemRaw(`${key}:${ENCODING_FILE[encoding]}`);
|
|
104
|
+
if (!bytes || bytes.byteLength !== meta.size[encoding]) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
return { meta, body: { [encoding]: bytes } };
|
|
108
|
+
}
|
|
109
|
+
async function renderPage({ event, next, storage, key, path, rule, encodings, config }) {
|
|
110
|
+
const response = await toResponse(await next(), event);
|
|
111
|
+
if (!isCacheable(response)) {
|
|
112
|
+
return { response };
|
|
113
|
+
}
|
|
114
|
+
const raw = new Uint8Array(await response.arrayBuffer());
|
|
115
|
+
const compressed = await Promise.all(encodings.map((encoding) => compressStored({ encoding, raw, config })));
|
|
116
|
+
const body = {};
|
|
117
|
+
const size = {};
|
|
118
|
+
encodings.forEach((encoding, index) => {
|
|
119
|
+
body[encoding] = compressed[index];
|
|
120
|
+
size[encoding] = compressed[index].byteLength;
|
|
121
|
+
});
|
|
122
|
+
const headers = {};
|
|
123
|
+
response.headers.forEach((value, name) => {
|
|
124
|
+
if (!SKIPPED_HEADERS.has(name)) {
|
|
125
|
+
headers[name] = value;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
const meta = {
|
|
129
|
+
path,
|
|
130
|
+
status: response.status,
|
|
131
|
+
headers,
|
|
132
|
+
etag: `W/"${createHash("sha1").update(raw).digest("base64url")}"`,
|
|
133
|
+
createdAt: Date.now(),
|
|
134
|
+
version: config.version,
|
|
135
|
+
size
|
|
136
|
+
};
|
|
137
|
+
const store = useStorage(storage);
|
|
138
|
+
const ttl = rule.staleMaxAge === void 0 ? void 0 : { ttl: rule.maxAge + rule.staleMaxAge };
|
|
139
|
+
try {
|
|
140
|
+
await Promise.all(encodings.map((encoding) => store.setItemRaw(`${key}:${ENCODING_FILE[encoding]}`, body[encoding], ttl)));
|
|
141
|
+
await store.setItem(`${key}:meta`, meta, ttl);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
console.error("[compressed-cache] storing failed", error);
|
|
144
|
+
}
|
|
145
|
+
return { page: { meta, body } };
|
|
146
|
+
}
|
|
147
|
+
function sendPage({ event, page, source, target, state, statusHeader }) {
|
|
148
|
+
const headers = new Headers(page.meta.headers);
|
|
149
|
+
headers.set("etag", page.meta.etag);
|
|
150
|
+
headers.append("vary", "accept-encoding");
|
|
151
|
+
if (statusHeader) {
|
|
152
|
+
headers.set(statusHeader, state);
|
|
153
|
+
}
|
|
154
|
+
if (matchesEtag({ ifNoneMatch: event.req.headers.get("if-none-match"), etag: page.meta.etag })) {
|
|
155
|
+
return new Response(null, { status: 304, headers });
|
|
156
|
+
}
|
|
157
|
+
const stored = page.body[source];
|
|
158
|
+
const body = source === target ? stored : convert({ body: stored, from: source, to: target });
|
|
159
|
+
if (target) {
|
|
160
|
+
headers.set("content-encoding", target);
|
|
161
|
+
}
|
|
162
|
+
headers.set("content-length", String(body.byteLength));
|
|
163
|
+
return new Response(event.req.method === "HEAD" ? null : body, { status: page.meta.status, headers });
|
|
164
|
+
}
|
|
165
|
+
function revalidate({ event, key, path }) {
|
|
166
|
+
if (inflight.has(key)) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const task = serverFetch(new URL(path, event.url.origin), { headers: { [REVALIDATE_HEADER]: REVALIDATE_TOKEN } }).then(async (response) => {
|
|
170
|
+
await response.body?.cancel();
|
|
171
|
+
return void 0;
|
|
172
|
+
}).catch((error) => {
|
|
173
|
+
console.error("[compressed-cache] revalidation failed", error);
|
|
174
|
+
return void 0;
|
|
175
|
+
}).finally(() => inflight.delete(key));
|
|
176
|
+
inflight.set(key, task);
|
|
177
|
+
event.req.waitUntil?.(task);
|
|
178
|
+
}
|
|
179
|
+
async function findEntries({ storage = DEFAULT_STORAGE, prefix = DEFAULT_PREFIX, path, pathPrefix }) {
|
|
180
|
+
const store = useStorage(storage);
|
|
181
|
+
const bases = path ? [path, payloadPath(path)].map((pathname) => pathKey({ prefix, pathname })) : [prefix];
|
|
182
|
+
const metaKeys = (await Promise.all(bases.map((base) => store.getKeys(base)))).flat().filter((key) => key.endsWith(":meta"));
|
|
183
|
+
const entries = [];
|
|
184
|
+
for (const metaKey of metaKeys) {
|
|
185
|
+
if (!path) {
|
|
186
|
+
const meta = await store.getItem(metaKey);
|
|
187
|
+
if (!meta?.path.startsWith(pathPrefix ?? "/")) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
entries.push(metaKey.slice(0, -":meta".length));
|
|
192
|
+
}
|
|
193
|
+
return entries;
|
|
194
|
+
}
|
|
195
|
+
export async function expireCompressedCache(target) {
|
|
196
|
+
const store = useStorage(target.storage ?? DEFAULT_STORAGE);
|
|
197
|
+
const entries = await findEntries(target);
|
|
198
|
+
for (const entry of entries) {
|
|
199
|
+
const meta = await store.getItem(`${entry}:meta`);
|
|
200
|
+
if (meta) {
|
|
201
|
+
await store.setItem(`${entry}:meta`, { ...meta, expired: true });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return entries.length;
|
|
205
|
+
}
|
|
206
|
+
export async function removeCompressedCache(target) {
|
|
207
|
+
const entries = await findEntries(target);
|
|
208
|
+
await Promise.all(entries.map((entry) => removeEntry({ storage: target.storage ?? DEFAULT_STORAGE, entry })));
|
|
209
|
+
return entries.length;
|
|
210
|
+
}
|
|
211
|
+
export async function pruneCompressedCache({ storage = DEFAULT_STORAGE, prefix = DEFAULT_PREFIX, ...by }) {
|
|
212
|
+
const store = useStorage(storage);
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
const metaKeys = (await store.getKeys(prefix)).filter((key) => key.endsWith(":meta"));
|
|
215
|
+
let removed = 0;
|
|
216
|
+
for (const metaKey of metaKeys) {
|
|
217
|
+
const meta = await store.getItem(metaKey);
|
|
218
|
+
const rule = meta && "rule" in by ? by.rule({ pathname: pagePathname(meta.path) }) : void 0;
|
|
219
|
+
const lifetime = "olderThan" in by ? by.olderThan : rule ? rule.maxAge + (rule.staleMaxAge ?? 0) : 0;
|
|
220
|
+
if (meta && meta.createdAt + lifetime * 1e3 > now) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
await removeEntry({ storage, entry: metaKey.slice(0, -":meta".length) });
|
|
224
|
+
removed++;
|
|
225
|
+
}
|
|
226
|
+
return removed;
|
|
227
|
+
}
|
|
228
|
+
export function defineCompressedCache(options) {
|
|
229
|
+
const storage = options.storage ?? DEFAULT_STORAGE;
|
|
230
|
+
const prefix = options.prefix ?? DEFAULT_PREFIX;
|
|
231
|
+
const statusHeader = options.statusHeader ?? "x-cache";
|
|
232
|
+
const encodings = ENCODINGS.filter((encoding) => (options.encodings ?? ENCODINGS).includes(encoding));
|
|
233
|
+
if (!encodings.length) {
|
|
234
|
+
throw new Error("[compressed-cache] `encodings` needs at least one of br or gzip");
|
|
235
|
+
}
|
|
236
|
+
return defineMiddleware(async (event, next) => {
|
|
237
|
+
if (import.meta.dev || import.meta.prerender || event.req.method !== "GET" && event.req.method !== "HEAD") {
|
|
238
|
+
return next();
|
|
239
|
+
}
|
|
240
|
+
const { pathname, search } = event.url;
|
|
241
|
+
const payload = pathname.endsWith(PAYLOAD_SUFFIX);
|
|
242
|
+
const rule = options.rule({ pathname: pagePathname(pathname) });
|
|
243
|
+
if (!rule || rule.bypassCookies?.length && hasCookie({ cookieHeader: event.req.headers.get("cookie"), names: rule.bypassCookies })) {
|
|
244
|
+
return next();
|
|
245
|
+
}
|
|
246
|
+
const { query, extra, currentBuild } = normalizeQuery({ search, allowQuery: rule.allowQuery, payload, version: options.version });
|
|
247
|
+
const key = entryKey({ prefix, pathname, query });
|
|
248
|
+
const path = query ? `${pathname}?${query}` : pathname;
|
|
249
|
+
if (event.req.headers.get(REVALIDATE_HEADER) === REVALIDATE_TOKEN) {
|
|
250
|
+
const { response } = await renderPage({ event, next, storage, key, path, rule, encodings, config: options });
|
|
251
|
+
if (response && response.status < 500) {
|
|
252
|
+
await removeEntry({ storage, entry: key });
|
|
253
|
+
}
|
|
254
|
+
return response ?? new Response(null, { status: 204 });
|
|
255
|
+
}
|
|
256
|
+
const acceptEncoding = event.req.headers.get("accept-encoding");
|
|
257
|
+
const accepted = negotiateEncoding(acceptEncoding, encodings);
|
|
258
|
+
const source = accepted ?? encodings[0];
|
|
259
|
+
const target = accepted ?? negotiateEncoding(acceptEncoding);
|
|
260
|
+
const cached = await readPage({ storage, key, encoding: source });
|
|
261
|
+
if (cached && (payload || cached.meta.version === options.version)) {
|
|
262
|
+
const age = Date.now() - cached.meta.createdAt;
|
|
263
|
+
if (!cached.meta.expired && age <= rule.maxAge * 1e3) {
|
|
264
|
+
return sendPage({ event, page: cached, source, target, state: "HIT", statusHeader });
|
|
265
|
+
}
|
|
266
|
+
if (currentBuild && (rule.staleMaxAge === void 0 || rule.staleMaxAge > 0 && age <= (rule.maxAge + rule.staleMaxAge) * 1e3)) {
|
|
267
|
+
revalidate({ event, key, path });
|
|
268
|
+
return sendPage({ event, page: cached, source, target, state: "STALE", statusHeader });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (extra || !currentBuild || event.req.method === "HEAD") {
|
|
272
|
+
return next();
|
|
273
|
+
}
|
|
274
|
+
const pending = inflight.get(key);
|
|
275
|
+
if (pending) {
|
|
276
|
+
const page = await pending ?? await readPage({ storage, key, encoding: source });
|
|
277
|
+
return page ? sendPage({ event, page, source, target, state: "MISS", statusHeader }) : next();
|
|
278
|
+
}
|
|
279
|
+
const task = renderPage({ event, next, storage, key, path, rule, encodings, config: options });
|
|
280
|
+
inflight.set(key, task.then(({ page }) => page, () => void 0));
|
|
281
|
+
try {
|
|
282
|
+
const { response, page } = await task;
|
|
283
|
+
return page ? sendPage({ event, page, source, target, state: "MISS", statusHeader }) : response;
|
|
284
|
+
} finally {
|
|
285
|
+
inflight.delete(key);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { useRuntimeConfig } from "nitro/runtime-config";
|
|
2
|
+
import { defineCompressedCache } from "./compressed-cache.js";
|
|
3
|
+
import { compressedCacheRule } from "./server-utils.js";
|
|
4
|
+
export default defineCompressedCache({
|
|
5
|
+
...useRuntimeConfig().compressedCache,
|
|
6
|
+
rule: compressedCacheRule
|
|
7
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CompressedCacheRule } from './compressed-cache.js';
|
|
2
|
+
type Target = {
|
|
3
|
+
path: string;
|
|
4
|
+
} | {
|
|
5
|
+
pathPrefix: string;
|
|
6
|
+
};
|
|
7
|
+
/** The `compressedCache` route rule of a page pathname, from the app's current route rules. */
|
|
8
|
+
export declare function compressedCacheRule({ pathname }: {
|
|
9
|
+
pathname: string;
|
|
10
|
+
}): CompressedCacheRule | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Marks cached copies stale, each is served once more while it re-renders in the background.
|
|
13
|
+
* `{ path: '/blog/post' }` for one page or `{ pathPrefix: '/blog/' }` for a section.
|
|
14
|
+
*
|
|
15
|
+
* @returns How many entries were expired.
|
|
16
|
+
*/
|
|
17
|
+
export declare function expireCompressedCache(target: Target): Promise<number>;
|
|
18
|
+
/**
|
|
19
|
+
* Deletes cached copies, the next request renders fresh.
|
|
20
|
+
* `{ path: '/blog/post' }` for one page or `{ pathPrefix: '/blog/' }` for a section.
|
|
21
|
+
*
|
|
22
|
+
* @returns How many entries were removed.
|
|
23
|
+
*/
|
|
24
|
+
export declare function removeCompressedCache(target: Target): Promise<number>;
|
|
25
|
+
/**
|
|
26
|
+
* Deletes entries past the `maxAge` (plus `staleMaxAge` when set) of their page's current route rule,
|
|
27
|
+
* and entries whose page no longer has a rule, on any storage driver.
|
|
28
|
+
* Pass `olderThan` (seconds) to delete by render age instead, for example `{ olderThan: 7 * 24 * 60 * 60 }`.
|
|
29
|
+
*
|
|
30
|
+
* @returns How many entries were removed.
|
|
31
|
+
*/
|
|
32
|
+
export declare function pruneCompressedCache({ olderThan }?: {
|
|
33
|
+
olderThan?: number;
|
|
34
|
+
}): Promise<number>;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getRouteRules } from "nitro/app";
|
|
2
|
+
import { useRuntimeConfig } from "nitro/runtime-config";
|
|
3
|
+
import { expireCompressedCache as expire, pruneCompressedCache as prune, removeCompressedCache as remove } from "./compressed-cache.js";
|
|
4
|
+
export function compressedCacheRule({ pathname }) {
|
|
5
|
+
return getRouteRules("GET", pathname).routeRules.compressedCache;
|
|
6
|
+
}
|
|
7
|
+
function location() {
|
|
8
|
+
const { storage, prefix } = useRuntimeConfig().compressedCache;
|
|
9
|
+
return { storage, prefix };
|
|
10
|
+
}
|
|
11
|
+
export function expireCompressedCache(target) {
|
|
12
|
+
return expire({ ...location(), ...target });
|
|
13
|
+
}
|
|
14
|
+
export function removeCompressedCache(target) {
|
|
15
|
+
return remove({ ...location(), ...target });
|
|
16
|
+
}
|
|
17
|
+
export function pruneCompressedCache({ olderThan } = {}) {
|
|
18
|
+
return olderThan === void 0 ? prune({ ...location(), rule: compressedCacheRule }) : prune({ ...location(), olderThan });
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useRuntimeConfig } from "nitro/runtime-config";
|
|
2
|
+
import { defineTask } from "nitro/task";
|
|
3
|
+
import { pruneCompressedCache } from "../server-utils.js";
|
|
4
|
+
export default defineTask({
|
|
5
|
+
meta: { name: "compressed-cache:prune", description: "Prune expired compressed cache entries" },
|
|
6
|
+
async run() {
|
|
7
|
+
const { cleanup } = useRuntimeConfig().compressedCache;
|
|
8
|
+
return { result: await pruneCompressedCache({ olderThan: cleanup.olderThan }) };
|
|
9
|
+
}
|
|
10
|
+
});
|
package/dist/types.d.mts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NuxtModule } from '@nuxt/schema'
|
|
2
|
+
|
|
3
|
+
import type { default as Module } from './module.mjs'
|
|
4
|
+
|
|
5
|
+
export type ModuleOptions = typeof Module extends NuxtModule<infer O> ? Partial<O> : Record<string, any>
|
|
6
|
+
|
|
7
|
+
export { default } from './module.mjs'
|
|
8
|
+
|
|
9
|
+
export { type CompressedCacheModuleOptions } from './module.mjs'
|
package/package.json
CHANGED
|
@@ -1,6 +1,72 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nuxt-compressed-cache",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.1",
|
|
5
|
+
"description": "Serve Nuxt route rules from pre-compressed (brotli/gzip) cached copies, with stale-while-revalidate",
|
|
6
|
+
"author": "Manthankumar Satani",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"nuxt",
|
|
10
|
+
"nuxt-module",
|
|
11
|
+
"nitro",
|
|
12
|
+
"cache",
|
|
13
|
+
"brotli",
|
|
14
|
+
"gzip",
|
|
15
|
+
"swr"
|
|
16
|
+
],
|
|
17
|
+
"imports": {
|
|
18
|
+
"#src/*": "./src/*.ts"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/types.d.mts",
|
|
23
|
+
"import": "./dist/module.mjs"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"main": "./dist/module.mjs",
|
|
27
|
+
"typesVersions": {
|
|
28
|
+
"*": {
|
|
29
|
+
".": [
|
|
30
|
+
"./dist/types.d.mts"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist"
|
|
36
|
+
],
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"nuxt": ">=5.0.0-0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@antfu/eslint-config": "9.5.1",
|
|
42
|
+
"@nuxt/cli": "3.37.0",
|
|
43
|
+
"@nuxt/module-builder": "1.0.3",
|
|
44
|
+
"@nuxt/test-utils": "4.3.4",
|
|
45
|
+
"@types/node": "24.19.2",
|
|
46
|
+
"changelogen": "0.6.2",
|
|
47
|
+
"eslint": "10.13.0",
|
|
48
|
+
"nitro": "3.0.260903-beta",
|
|
49
|
+
"nuxt": "npm:nuxt-nightly@5.0.0-2610211357-d296167",
|
|
50
|
+
"typescript": "6.0.2",
|
|
51
|
+
"vitest": "5.0.5",
|
|
52
|
+
"vue": "3.5.44"
|
|
53
|
+
},
|
|
54
|
+
"devEngines": {
|
|
55
|
+
"packageManager": {
|
|
56
|
+
"name": "pnpm",
|
|
57
|
+
"version": "12.15.0",
|
|
58
|
+
"onFail": "download"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"dev": "nuxt dev playground",
|
|
63
|
+
"dev:build": "nuxt build playground",
|
|
64
|
+
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
|
|
65
|
+
"lint": "eslint .",
|
|
66
|
+
"lint:fix": "eslint . --fix",
|
|
67
|
+
"test": "vitest run",
|
|
68
|
+
"test:watch": "vitest watch",
|
|
69
|
+
"test:types": "tsc --noEmit",
|
|
70
|
+
"release": "pnpm lint && pnpm test && pnpm test:types && pnpm prepack && changelogen --release && pnpm publish && git push --follow-tags"
|
|
71
|
+
}
|
|
6
72
|
}
|