@iqual/nuxt-icms 1.6.1 → 1.6.2-rc.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/README.md +86 -0
- package/__tests__/anchors.spec.ts +43 -0
- package/__tests__/contract/blokkliComponents.spec.ts +59 -0
- package/__tests__/contract/componentFragments.spec.ts +24 -0
- package/__tests__/contract/languages.spec.ts +63 -0
- package/__tests__/contract/project.ts +144 -0
- package/__tests__/hreflang.spec.ts +39 -0
- package/{tests → __tests__}/pages.spec.ts +14 -5
- package/app.config.ts +15 -0
- package/app.vue +3 -21
- package/assets/css/backend.css +56 -0
- package/assets/css/base/base.css +2 -2
- package/assets/css/base/typography.css +2 -2
- package/assets/css/ckeditor.css +3 -4
- package/assets/css/components/decorations.css +1 -1
- package/assets/css/components/editor.css +1 -1
- package/assets/css/components/grid-container.css +19 -19
- package/assets/css/fe-fixes.css +6 -12
- package/assets/css/theme/colors/color-primary.css +33 -11
- package/assets/css/theme/colors/color-secondary.css +33 -11
- package/assets/css/utilities/utilities.css +1 -1
- package/assets/symbols/anchor.svg +3 -0
- package/blokkli.editAdapter.ts +56 -0
- package/bundles.ts +6 -6
- package/cache/__tests__/pageCache.spec.ts +540 -0
- package/cache/cacheTagRegistry.ts +124 -0
- package/cache/enabledForRequest.ts +21 -0
- package/cache/multiCache.serverOptions.ts +58 -0
- package/cache/pageCache.ts +360 -0
- package/components/Blokkli/Anchor/icon.svg +3 -0
- package/components/Blokkli/Anchor/index.vue +13 -0
- package/components/Blokkli/AnchorNavigation/icon.svg +8 -0
- package/components/Blokkli/AnchorNavigation/index.vue +9 -0
- package/components/Blokkli/Card/Element/index.vue +0 -1
- package/components/Blokkli/Collapsible/Element/index.vue +5 -0
- package/components/Blokkli/FAQ/Element/index.vue +0 -1
- package/components/Blokkli/Fragment/Separator/icon.svg +4 -0
- package/components/Blokkli/Fragment/Separator/index.vue +87 -0
- package/components/Blokkli/Gallery/index.vue +1 -3
- package/components/Blokkli/IconText/Element/index.vue +0 -1
- package/components/Blokkli/Introduction/index.vue +0 -1
- package/components/Blokkli/Listing/icon.svg +9 -0
- package/components/Blokkli/Listing/index.vue +24 -0
- package/components/Blokkli/Logo/Element/index.vue +0 -1
- package/components/Blokkli/Table/index.vue +0 -1
- package/components/Blokkli/Team/Element/index.vue +0 -1
- package/components/Blokkli/Team/Layout/index.vue +0 -1
- package/components/Blokkli/Testimonial/Element/index.vue +0 -1
- package/components/Blokkli/Text/index.vue +0 -1
- package/components/DesignSystem/Atoms/Button.vue +3 -0
- package/components/DesignSystem/Atoms/GridRow.vue +3 -1
- package/components/DesignSystem/Molecules/ButtonGroup.vue +5 -4
- package/components/DesignSystem/Molecules/IconText.vue +6 -6
- package/components/DesignSystem/Molecules/MediaLightbox.vue +8 -8
- package/components/DesignSystem/Molecules/MediaSlider.vue +9 -11
- package/components/DesignSystem/Molecules/Partnership.vue +7 -4
- package/components/DesignSystem/Molecules/Table.vue +35 -19
- package/components/DesignSystem/Molecules/TitleComposition.vue +46 -15
- package/components/DesignSystem/Molecules/iFrame.vue +2 -2
- package/components/DesignSystem/Organisms/CallToAction.vue +8 -7
- package/components/DesignSystem/Organisms/CallToActionFeature.vue +8 -7
- package/components/DesignSystem/Organisms/CallToActionFullwidth.vue +9 -8
- package/components/DesignSystem/Organisms/ContentBlock.vue +26 -16
- package/components/DesignSystem/Organisms/FacetedListing.vue +38 -17
- package/components/DesignSystem/Organisms/FactCard.vue +6 -7
- package/components/DesignSystem/Organisms/FeatureCard.vue +14 -14
- package/components/DesignSystem/Organisms/FlipTeaser.vue +19 -19
- package/components/DesignSystem/Organisms/HeaderHero.vue +11 -11
- package/components/DesignSystem/Organisms/HeaderTextImage.vue +10 -10
- package/components/DesignSystem/Organisms/LinkList.vue +20 -12
- package/components/DesignSystem/Organisms/LogoElement.vue +7 -4
- package/components/DesignSystem/Organisms/Map.vue +1 -1
- package/components/DesignSystem/Organisms/SearchResults.vue +15 -6
- package/components/DesignSystem/Organisms/SpotlightCard.vue +18 -18
- package/components/DesignSystem/Organisms/TeamElement.vue +19 -18
- package/components/DesignSystem/Organisms/TeaserCard.vue +16 -17
- package/components/DesignSystem/Organisms/TeaserSimple.vue +4 -31
- package/components/DesignSystem/Organisms/Testimonial.vue +13 -13
- package/components/DesignSystem/Organisms/TextCard.vue +10 -10
- package/components/DesignSystem/Organisms/TimelineElement.vue +20 -19
- package/components/DesignSystem/Organisms/TwoColumnTextElement.vue +5 -5
- package/components/DesignSystem/README.md +59 -40
- package/components/Drupal/Breadcrumb/index.vue +25 -12
- package/components/Drupal/Messages/index.vue +9 -4
- package/components/Drupal/Webform/index.vue +271 -51
- package/components/Drupal/Webform/mutation.graphql +17 -1
- package/components/Form/HiddenInput.vue +1 -4
- package/components/Form/SelectInput.vue +1 -1
- package/components/Form/SubmitButton.vue +2 -2
- package/components/Form/TextAreaInput.vue +3 -3
- package/components/Form/TextEmailInput.vue +1 -1
- package/components/Form/TextInput.vue +1 -1
- package/components/Form/TextPhoneInput.vue +1 -1
- package/components/Form/WebformMarkup.vue +1 -1
- package/components/Form/composables/useFormDefaultValue.ts +9 -13
- package/components/Form/composables/useFormElement.ts +5 -3
- package/components/Form/utils/types.ts +3 -3
- package/components/Media/Image/fragment.graphql +4 -0
- package/components/Media/Image/index.vue +27 -17
- package/components/Media/RemoteVideo/index.vue +3 -3
- package/components/Media/Video/index.vue +1 -1
- package/components/Node/Icms/Page/SearchResult/fragment.graphql +1 -1
- package/components/Node/Icms/Page/SearchResult/index.vue +2 -2
- package/components/Node/Icms/Page/Teaser/index.vue +1 -1
- package/components/Node/Icms/Page/index.vue +8 -2
- package/components/Paragraph/Anchor/fragment.graphql +4 -0
- package/components/Paragraph/Anchor/index.vue +48 -0
- package/components/Paragraph/AnchorNavigation/fragment.graphql +3 -0
- package/components/Paragraph/AnchorNavigation/index.vue +154 -0
- package/components/Paragraph/BlokkliFragment/fragment.graphql +5 -0
- package/components/Paragraph/Button/Element/fragment.graphql +1 -1
- package/components/Paragraph/Button/Element/index.vue +13 -8
- package/components/Paragraph/Card/Element/index.vue +1 -1
- package/components/Paragraph/Card/Layout/index.vue +9 -9
- package/components/Paragraph/Collapsible/Element/index.vue +2 -2
- package/components/Paragraph/Collapsible/Layout/index.vue +19 -18
- package/components/Paragraph/Contact/index.vue +17 -17
- package/components/Paragraph/Downloads/fragment.graphql +1 -1
- package/components/Paragraph/Downloads/index.vue +13 -13
- package/components/Paragraph/FAQ/Element/index.vue +4 -3
- package/components/Paragraph/FAQ/Layout/index.vue +42 -21
- package/components/Paragraph/FromLibrary/fragment.graphql +17 -0
- package/components/Paragraph/Gallery/index.vue +14 -14
- package/components/Paragraph/HeroImage/index.vue +26 -19
- package/components/Paragraph/IconText/Element/index.vue +4 -4
- package/components/Paragraph/IconText/Layout/index.vue +12 -12
- package/components/Paragraph/Introduction/index.vue +17 -17
- package/components/Paragraph/LinkLists/Element/fragment.graphql +1 -1
- package/components/Paragraph/LinkLists/Element/index.vue +5 -5
- package/components/Paragraph/LinkLists/Layout/index.vue +9 -9
- package/components/Paragraph/Listing/fragment.graphql +14 -0
- package/components/Paragraph/Listing/index.vue +91 -0
- package/components/Paragraph/Logo/Element/fragment.graphql +1 -1
- package/components/Paragraph/Logo/Element/index.vue +7 -4
- package/components/Paragraph/Media/index.vue +23 -21
- package/components/Paragraph/Partnerships/Element/fragment.graphql +1 -1
- package/components/Paragraph/Partnerships/Element/index.vue +10 -7
- package/components/Paragraph/Partnerships/Layout/index.vue +13 -13
- package/components/Paragraph/SingleTestimonial/index.vue +13 -13
- package/components/Paragraph/Table/index.vue +12 -12
- package/components/Paragraph/Team/Element/index.vue +16 -14
- package/components/Paragraph/Team/Layout/index.vue +9 -9
- package/components/Paragraph/Teasers/index.vue +14 -17
- package/components/Paragraph/Testimonial/Element/index.vue +19 -18
- package/components/Paragraph/Testimonial/Layout/index.vue +24 -18
- package/components/Paragraph/Text/fragment.graphql +1 -1
- package/components/Paragraph/Text/index.vue +8 -8
- package/components/Paragraph/TextMedia/index.vue +22 -22
- package/components/Paragraph/Timeline/Element/index.vue +19 -19
- package/components/Paragraph/Timeline/Layout/index.vue +51 -20
- package/components/Paragraph/TwoColumnText/Element/index.vue +13 -13
- package/components/Paragraph/TwoColumnText/Layout/index.vue +1 -1
- package/components/Paragraph/iFrame/index.vue +8 -8
- package/components/Search/Facets/FacetDropdownUI.vue +25 -31
- package/components/Search/Facets/FacetedSearchUI.vue +72 -48
- package/components/Search/Facets/fragment.graphql +20 -15
- package/components/Section/Footer/index.vue +55 -32
- package/components/Section/Header/Buttons.vue +4 -5
- package/components/Section/Header/DesktopMainMenu.vue +5 -4
- package/components/Section/Header/DesktopMainMenuDropdown.vue +5 -5
- package/components/Section/Header/LanguageDropdown.vue +2 -3
- package/components/Section/Header/Logo.vue +3 -3
- package/components/Section/Header/MainButtons.vue +4 -5
- package/components/Section/Header/MetaButtons.vue +5 -6
- package/components/Section/Header/MetaMenu.vue +1 -1
- package/components/Section/Header/MobileMainMenu.vue +1 -1
- package/components/Section/Header/MobileMainMenuItem.vue +2 -2
- package/components/Section/Header/index.vue +6 -13
- package/composables/__tests__/useImageSources.spec.ts +91 -0
- package/composables/breadcrumb.ts +12 -7
- package/composables/useAccordionScroll.ts +12 -4
- package/composables/useAllowedFragments.ts +14 -0
- package/composables/useAnchors.ts +48 -0
- package/composables/useChildTitleWeight.ts +1 -1
- package/composables/useCurrentLangcodeEnum.ts +1 -1
- package/composables/useCurrentNodeId.ts +8 -0
- package/composables/useDrupalUrl.ts +15 -0
- package/composables/useEnrichFacetLabels.ts +8 -6
- package/composables/useEntityRegistry.ts +3 -3
- package/composables/useGlobalConfig.ts +1 -2
- package/composables/useGraphqlCacheability.ts +12 -1
- package/composables/useHreflangLinks.ts +54 -0
- package/composables/useImageSources.ts +70 -28
- package/composables/useInitData.ts +1 -2
- package/composables/useIsHomepage.ts +1 -1
- package/composables/useLinkUrl.ts +30 -0
- package/composables/useMediaLightbox.ts +62 -50
- package/composables/useMenu.ts +1 -3
- package/composables/useMessageBus.ts +14 -7
- package/composables/useMobileMenuOverlayBus.ts +1 -1
- package/composables/useNavigationOffset.ts +49 -6
- package/composables/usePageCache.ts +33 -0
- package/composables/useSiteShell.ts +11 -0
- package/composables/useTheme.ts +17 -13
- package/config/__tests__/stubs/nuxt-multi-cache-config.ts +2 -0
- package/config/blokkli.ts +7 -13
- package/config/easyTexts.ts +1 -1
- package/config/graphqlMiddleware.ts +11 -4
- package/config/index.ts +5 -2
- package/config/languageNegotiation.ts +1 -1
- package/config/multiCache.ts +6 -1
- package/config/postcss-drupal/postcss.config.cjs +2 -2
- package/config/runtimeConfig.ts +7 -2
- package/config/svgIconSprite.ts +1 -2
- package/config/vuepal.ts +16 -18
- package/error.vue +1 -22
- package/layouts/default.vue +2 -6
- package/layouts/error.vue +7 -15
- package/modules/blokkliFragments.ts +76 -0
- package/modules/dynamicEntityComponentResolver.ts +47 -47
- package/modules/dynamicParagraphResolver.ts +43 -8
- package/modules/pageCache.ts +106 -0
- package/nuxt.config.ts +62 -33
- package/package.json +20 -19
- package/pages/[...slug]/index.vue +25 -4
- package/pages/index.vue +3 -3
- package/pages/node/[nid]/index.vue +2 -3
- package/patches/@blokkli+editor+2.0.0-alpha.13.patch +15 -1
- package/plugins/currentNode.ts +17 -0
- package/plugins/graphqlMiddleware.ts +10 -2
- package/plugins/initData.ts +15 -7
- package/plugins/registerNodeIcmsPageComponents.ts +6 -2
- package/plugins/siteShell.client.ts +94 -0
- package/queries/facetTermLabels.graphql +1 -5
- package/queries/fragment.linkUrl.graphql +12 -0
- package/queries/query.contentSearch.graphql +17 -2
- package/queries/query.initData.graphql +1 -1
- package/scripts/create-shade.ts +13 -4
- package/scripts/update-icms.ts +3 -1
- package/server/__tests__/graphqlMiddleware.serverOptions.spec.ts +2 -2
- package/server/__tests__/turnstile.spec.ts +119 -0
- package/server/api/icms/blokkli/last-changed.get.ts +24 -0
- package/server/api/icon/[...params].ts +3 -9
- package/server/graphqlMiddleware.serverOptions.ts +9 -8
- package/server/helpers/index.ts +0 -1
- package/server/middleware/turnstile.ts +51 -0
- package/server/plugins/frameAncestors.ts +20 -0
- package/server/plugins/pageCache.ts +59 -0
- package/server/utils/__tests__/accessDeniedHandover.spec.ts +49 -0
- package/server/utils/__tests__/backendRequestHeaders.spec.ts +115 -0
- package/server/utils/accessDeniedHandover.ts +48 -0
- package/server/utils/backendRequestHeaders.ts +51 -0
- package/server/utils/cacheability.ts +4 -4
- package/shared/__tests__/blokkliState.spec.ts +54 -0
- package/shared/__tests__/csp.spec.ts +53 -0
- package/shared/__tests__/drupalUrl.spec.ts +50 -0
- package/shared/blokkliState.ts +40 -0
- package/shared/csp.ts +47 -0
- package/shared/drupalUrl.ts +30 -0
- package/tailwind.config.ts +0 -5
- package/types/breadcrumb.ts +22 -0
- package/types/global.ts +15 -14
- package/types/langcode.ts +16 -0
- package/types/layerConfig.ts +84 -0
- package/utils/pageCache.ts +95 -0
- package/utils/turnstile.ts +35 -0
- package/composables/useWebformEnrichment.ts +0 -68
- package/server/__tests__/graphqlMiddleware.ts +0 -41
- package/server/multiCache.serverOptions.ts +0 -32
- package/vitest.config.mts +0 -28
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
createEvent,
|
|
4
|
+
getResponseHeaders,
|
|
5
|
+
setResponseHeader,
|
|
6
|
+
setResponseStatus,
|
|
7
|
+
} from 'h3'
|
|
8
|
+
import { IncomingMessage, ServerResponse } from 'node-mock-http'
|
|
9
|
+
import { createStorage } from 'unstorage'
|
|
10
|
+
import lruCacheDriver from 'unstorage/drivers/lru-cache'
|
|
11
|
+
import { NuxtMultiCacheCDNHelper } from 'nuxt-multi-cache/dist/runtime/helpers/CDNHelper.js'
|
|
12
|
+
import { NuxtMultiCacheRouteCacheHelper } from 'nuxt-multi-cache/dist/runtime/helpers/RouteCacheHelper.js'
|
|
13
|
+
import { CacheTagInvalidator } from 'nuxt-multi-cache/dist/runtime/helpers/CacheTagInvalidator.js'
|
|
14
|
+
import {
|
|
15
|
+
decodeRouteCacheItem,
|
|
16
|
+
encodeRouteCacheItem,
|
|
17
|
+
handleRawCacheData,
|
|
18
|
+
} from 'nuxt-multi-cache/dist/runtime/helpers/cacheItem.js'
|
|
19
|
+
import multiCache from '../../config/multiCache'
|
|
20
|
+
import runtimeConfig from '../../config/runtimeConfig'
|
|
21
|
+
import {
|
|
22
|
+
PAGE_CACHE_DEFAULTS,
|
|
23
|
+
alterCachedHeaders,
|
|
24
|
+
applyCacheControlHeaders,
|
|
25
|
+
boundedLruOptions,
|
|
26
|
+
buildRouteCacheKey,
|
|
27
|
+
cacheEntrySize,
|
|
28
|
+
containerMemoryLimit,
|
|
29
|
+
finalizeRouteCache,
|
|
30
|
+
hasDrupalSession,
|
|
31
|
+
pageCacheDefaults,
|
|
32
|
+
isBlokkliRequest,
|
|
33
|
+
resolvePageCacheConfig,
|
|
34
|
+
resolveRouteMaxSize,
|
|
35
|
+
routeCacheDriver,
|
|
36
|
+
} from '../pageCache'
|
|
37
|
+
import { LruCacheTagRegistry } from '../cacheTagRegistry'
|
|
38
|
+
|
|
39
|
+
const MB = 1024 * 1024
|
|
40
|
+
const NOW = Math.floor(Date.now() / 1000)
|
|
41
|
+
|
|
42
|
+
function freshEvent(path = '/de/foo', headers: Record<string, string> = {}) {
|
|
43
|
+
const req = new IncomingMessage()
|
|
44
|
+
req.url = path
|
|
45
|
+
req.headers = { host: 'example.ch', ...headers }
|
|
46
|
+
return createEvent(req, new ServerResponse())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Simulates what the SSR render leaves on the event: the CDN helper with the
|
|
51
|
+
* collected cacheability (applied to the response, as nuxt-multi-cache does)
|
|
52
|
+
* and optionally a route cache helper.
|
|
53
|
+
*/
|
|
54
|
+
function renderedEvent(
|
|
55
|
+
configure: (cdn: NuxtMultiCacheCDNHelper) => void,
|
|
56
|
+
headers: Record<string, string> = {},
|
|
57
|
+
) {
|
|
58
|
+
const event = freshEvent('/de/foo', headers)
|
|
59
|
+
const cdn = new NuxtMultiCacheCDNHelper(
|
|
60
|
+
NOW,
|
|
61
|
+
multiCache.cdn!.cacheControlHeader,
|
|
62
|
+
multiCache.cdn!.cacheTagHeader,
|
|
63
|
+
)
|
|
64
|
+
configure(cdn)
|
|
65
|
+
cdn.applyToEvent(event)
|
|
66
|
+
event.context.multiCache = { cdn }
|
|
67
|
+
return event
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cacheablePage(cdn: NuxtMultiCacheCDNHelper) {
|
|
71
|
+
cdn.public().setNumeric('sharedMaxAge', 31536000).addTags(['N:12'])
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('page cache configuration', () => {
|
|
75
|
+
it('ships the defaults in the runtime config', () => {
|
|
76
|
+
// DDEV sets NODE_ENV=development for the whole container.
|
|
77
|
+
expect(runtimeConfig.pageCache).toEqual(
|
|
78
|
+
pageCacheDefaults(process.env.NODE_ENV === 'development'),
|
|
79
|
+
)
|
|
80
|
+
expect(PAGE_CACHE_DEFAULTS.mode).toBe('default')
|
|
81
|
+
expect(PAGE_CACHE_DEFAULTS.routeMaxSize).toBe('auto')
|
|
82
|
+
expect(PAGE_CACHE_DEFAULTS.routeCompression).toBe(true)
|
|
83
|
+
expect(PAGE_CACHE_DEFAULTS.routeMaxAge).toBe(7 * 86_400)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('turns the route cache off in development', () => {
|
|
87
|
+
expect(pageCacheDefaults(false)).toEqual(PAGE_CACHE_DEFAULTS)
|
|
88
|
+
const dev = pageCacheDefaults(true)
|
|
89
|
+
expect(dev).toEqual({ ...PAGE_CACHE_DEFAULTS, routeMaxSize: 0 })
|
|
90
|
+
expect(resolvePageCacheConfig(dev).routeMaxSize).toBe(0)
|
|
91
|
+
// NUXT_PAGE_CACHE_ROUTE_MAX_SIZE=auto turns it back on.
|
|
92
|
+
expect(
|
|
93
|
+
resolvePageCacheConfig({ ...dev, routeMaxSize: 'auto' }).routeMaxSize,
|
|
94
|
+
).toBe('auto')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('derives the route cache budget from the container memory', () => {
|
|
98
|
+
const auto = { routeMaxSize: 'auto' } as const
|
|
99
|
+
expect(resolveRouteMaxSize(auto, null)).toBe(24 * MB)
|
|
100
|
+
expect(resolveRouteMaxSize(auto, 160 * MB)).toBe(8 * MB)
|
|
101
|
+
expect(resolveRouteMaxSize(auto, 448 * MB)).toBe(
|
|
102
|
+
Math.floor(448 * MB * 0.03),
|
|
103
|
+
)
|
|
104
|
+
expect(resolveRouteMaxSize(auto, 1024 * MB)).toBe(
|
|
105
|
+
Math.floor(1024 * MB * 0.03),
|
|
106
|
+
)
|
|
107
|
+
expect(resolveRouteMaxSize(auto, 4096 * MB)).toBe(64 * MB)
|
|
108
|
+
expect(resolveRouteMaxSize({ routeMaxSize: 0 }, 4096 * MB)).toBe(0)
|
|
109
|
+
expect(resolveRouteMaxSize({ routeMaxSize: MB }, null)).toBe(MB)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('enables the route cache in the build', () => {
|
|
113
|
+
expect(multiCache.route?.enabled).toBe(true)
|
|
114
|
+
expect(multiCache.data?.enabled).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('accepts numeric strings from environment variables', () => {
|
|
118
|
+
expect(
|
|
119
|
+
resolvePageCacheConfig({
|
|
120
|
+
mode: 'varnish',
|
|
121
|
+
routeMaxSize: '1048576',
|
|
122
|
+
browserMaxAge: '0',
|
|
123
|
+
routeCompression: 'false',
|
|
124
|
+
}),
|
|
125
|
+
).toMatchObject({
|
|
126
|
+
mode: 'varnish',
|
|
127
|
+
routeMaxSize: MB,
|
|
128
|
+
browserMaxAge: 0,
|
|
129
|
+
routeCompression: false,
|
|
130
|
+
})
|
|
131
|
+
expect(resolvePageCacheConfig({ routeMaxSize: 'auto' }).routeMaxSize).toBe(
|
|
132
|
+
'auto',
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('falls back to the defaults for invalid values', () => {
|
|
137
|
+
expect(
|
|
138
|
+
resolvePageCacheConfig({
|
|
139
|
+
mode: 'redis',
|
|
140
|
+
routeMaxSize: 'lots',
|
|
141
|
+
dataMaxSize: -1,
|
|
142
|
+
}),
|
|
143
|
+
).toEqual(PAGE_CACHE_DEFAULTS)
|
|
144
|
+
expect(resolvePageCacheConfig(undefined)).toEqual(PAGE_CACHE_DEFAULTS)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('request classification', () => {
|
|
149
|
+
it.each([
|
|
150
|
+
['SSESSabc123=xyz', true],
|
|
151
|
+
['_ga=GA1.2; SSESS0f3a=xyz; other=1', true],
|
|
152
|
+
['SESSabc=xyz', true],
|
|
153
|
+
['_ga=GA1.2.3; cookie-agreed=2', false],
|
|
154
|
+
['tracking=SSESS', false],
|
|
155
|
+
['', false],
|
|
156
|
+
])('detects a Drupal session in "%s": %s', (cookie, expected) => {
|
|
157
|
+
expect(hasDrupalSession(cookie)).toBe(expected)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it.each([
|
|
161
|
+
['/blokkli/library-item/123', true],
|
|
162
|
+
['/de/foo?blokkliEditing=abc', true],
|
|
163
|
+
['/de/foo?x=1&blokkliPreview=1', true],
|
|
164
|
+
['/de/foo?blokkliPreview', true],
|
|
165
|
+
['/de/foo', false],
|
|
166
|
+
['/de/blokkli-tutorial', false],
|
|
167
|
+
['/de/foo?q=blokkliEditing', false],
|
|
168
|
+
])('detects the blökkli request "%s": %s', (path, expected) => {
|
|
169
|
+
expect(isBlokkliRequest(path)).toBe(expected)
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('route cache key', () => {
|
|
174
|
+
it('ignores cookies', () => {
|
|
175
|
+
expect(buildRouteCacheKey(freshEvent('/de/foo', { cookie: '_ga=1' }))).toBe(
|
|
176
|
+
buildRouteCacheKey(freshEvent('/de/foo', { cookie: '_ga=2' })),
|
|
177
|
+
)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('ignores tracking parameters and the parameter order', () => {
|
|
181
|
+
const plain = buildRouteCacheKey(freshEvent('/de/news?page=2&tag=a'))
|
|
182
|
+
expect(
|
|
183
|
+
buildRouteCacheKey(
|
|
184
|
+
freshEvent('/de/news?utm_source=nl&tag=a&gclid=x&page=2&fbclid=y'),
|
|
185
|
+
),
|
|
186
|
+
).toBe(plain)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('keeps relevant query parameters apart', () => {
|
|
190
|
+
expect(buildRouteCacheKey(freshEvent('/de/news?page=2'))).not.toBe(
|
|
191
|
+
buildRouteCacheKey(freshEvent('/de/news?page=3')),
|
|
192
|
+
)
|
|
193
|
+
expect(buildRouteCacheKey(freshEvent('/de/news?page=2'))).not.toBe(
|
|
194
|
+
buildRouteCacheKey(freshEvent('/de/news')),
|
|
195
|
+
)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('keeps hosts apart, including X-Forwarded-Host', () => {
|
|
199
|
+
const key = buildRouteCacheKey(freshEvent('/de/foo'))
|
|
200
|
+
expect(
|
|
201
|
+
buildRouteCacheKey(freshEvent('/de/foo', { host: 'other.ch' })),
|
|
202
|
+
).not.toBe(key)
|
|
203
|
+
expect(
|
|
204
|
+
buildRouteCacheKey(
|
|
205
|
+
freshEvent('/de/foo', { 'x-forwarded-host': 'evil.example' }),
|
|
206
|
+
),
|
|
207
|
+
).not.toBe(key)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('survives unstorage key normalization', async () => {
|
|
211
|
+
const storage = createStorage({ driver: lruCacheDriver({ max: 10 }) })
|
|
212
|
+
const paths = ['/de/news', '/de/news?page=2', '/de/news/page:2']
|
|
213
|
+
for (const path of paths) {
|
|
214
|
+
await storage.setItemRaw(buildRouteCacheKey(freshEvent(path)), path)
|
|
215
|
+
}
|
|
216
|
+
for (const path of paths) {
|
|
217
|
+
expect(
|
|
218
|
+
await storage.getItemRaw(buildRouteCacheKey(freshEvent(path))),
|
|
219
|
+
).toBe(path)
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe('bounded LRU storage', () => {
|
|
225
|
+
it('counts two-byte strings double', () => {
|
|
226
|
+
expect(cacheEntrySize('a'.repeat(100), '')).toBe(100)
|
|
227
|
+
expect(cacheEntrySize('ä'.repeat(100), '')).toBe(100)
|
|
228
|
+
expect(cacheEntrySize('’' + 'a'.repeat(99), '')).toBe(200)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('never grows beyond the byte budget', async () => {
|
|
232
|
+
const driver = lruCacheDriver(boundedLruOptions(MB))
|
|
233
|
+
const storage = createStorage({ driver })
|
|
234
|
+
const page = 'x'.repeat(100 * 1024)
|
|
235
|
+
for (let i = 0; i < 100; i++) {
|
|
236
|
+
await storage.setItemRaw(`page-${i}`, page + i)
|
|
237
|
+
}
|
|
238
|
+
const lru = driver.getInstance!()
|
|
239
|
+
expect(lru.calculatedSize).toBeLessThanOrEqual(MB)
|
|
240
|
+
expect(lru.size).toBeGreaterThan(5)
|
|
241
|
+
// Least recently used entries are evicted first.
|
|
242
|
+
expect(await storage.hasItem('page-0')).toBe(false)
|
|
243
|
+
expect(await storage.hasItem('page-99')).toBe(true)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('bounds the data cache, which stores serialized objects', async () => {
|
|
247
|
+
const driver = lruCacheDriver(boundedLruOptions(64 * 1024))
|
|
248
|
+
const storage = createStorage({ driver })
|
|
249
|
+
for (let i = 0; i < 100; i++) {
|
|
250
|
+
await storage.setItem(`data-${i}`, {
|
|
251
|
+
data: 'y'.repeat(4096),
|
|
252
|
+
cacheTags: ['N:' + i],
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
expect(driver.getInstance!().calculatedSize).toBeLessThanOrEqual(64 * 1024)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('rejects a single entry above a quarter of the budget', async () => {
|
|
259
|
+
const storage = createStorage({
|
|
260
|
+
driver: lruCacheDriver(boundedLruOptions(MB)),
|
|
261
|
+
})
|
|
262
|
+
await storage.setItemRaw('small', 'x'.repeat(1024))
|
|
263
|
+
await storage.setItemRaw('huge', 'x'.repeat(MB / 2))
|
|
264
|
+
expect(await storage.hasItem('huge')).toBe(false)
|
|
265
|
+
expect(await storage.hasItem('small')).toBe(true)
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
describe('route cache entry encoding', () => {
|
|
270
|
+
// Mostly ASCII markup with a typographic apostrophe, like a real page.
|
|
271
|
+
const page = '<p>It’s</p>' + '<div class="x">Hello</div>'.repeat(4000)
|
|
272
|
+
const item = encodeRouteCacheItem(page, {}, 200, -1, 0, false, ['N:1'])
|
|
273
|
+
|
|
274
|
+
it.each([true, false])(
|
|
275
|
+
'round-trips a page (compressed: %s)',
|
|
276
|
+
async (compress) => {
|
|
277
|
+
const storage = createStorage({ driver: routeCacheDriver(MB, compress) })
|
|
278
|
+
await storage.setItemRaw('key', item)
|
|
279
|
+
// What nuxt-multi-cache does when serving and when purging.
|
|
280
|
+
const raw = handleRawCacheData(await storage.getItemRaw('key'))
|
|
281
|
+
expect(decodeRouteCacheItem(raw!)?.data).toBe(page)
|
|
282
|
+
// The stats and inspect API endpoints.
|
|
283
|
+
expect(await storage.getItem('key')).toBe(item)
|
|
284
|
+
expect(await storage.getItemRaw('missing')).toBeNull()
|
|
285
|
+
},
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
it('stores pages in a fraction of their string size', async () => {
|
|
289
|
+
const [plain, gzipped] = await Promise.all(
|
|
290
|
+
[false, true].map(async (compress) => {
|
|
291
|
+
const driver = routeCacheDriver(MB, compress)
|
|
292
|
+
await createStorage({ driver }).setItemRaw('key', item)
|
|
293
|
+
return driver.getInstance!().calculatedSize
|
|
294
|
+
}),
|
|
295
|
+
)
|
|
296
|
+
// As a JS string the page counts two bytes per character.
|
|
297
|
+
expect(cacheEntrySize(item, 'key')).toBeGreaterThan(2 * item.length)
|
|
298
|
+
expect(plain).toBeLessThan(item.length * 1.01)
|
|
299
|
+
expect(gzipped).toBeLessThan(plain! / 10)
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
describe('container memory limit', () => {
|
|
304
|
+
const reader = (files: Record<string, string>) => (path: string) => {
|
|
305
|
+
if (path in files) {
|
|
306
|
+
return files[path]!
|
|
307
|
+
}
|
|
308
|
+
throw new Error('ENOENT')
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
it('reads the Platform.sh/Upsun container size', () => {
|
|
312
|
+
const config = JSON.stringify({ info: { limits: { memory: 448 } } })
|
|
313
|
+
expect(
|
|
314
|
+
containerMemoryLimit(
|
|
315
|
+
reader({
|
|
316
|
+
'/run/config.json': config,
|
|
317
|
+
'/sys/fs/cgroup/memory.max': '1073741824',
|
|
318
|
+
}),
|
|
319
|
+
),
|
|
320
|
+
).toBe(448 * MB)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('falls back to the cgroup limit', () => {
|
|
324
|
+
expect(
|
|
325
|
+
containerMemoryLimit(
|
|
326
|
+
reader({ '/sys/fs/cgroup/memory.max': '536870912\n' }),
|
|
327
|
+
),
|
|
328
|
+
).toBe(512 * MB)
|
|
329
|
+
expect(
|
|
330
|
+
containerMemoryLimit(
|
|
331
|
+
reader({
|
|
332
|
+
'/sys/fs/cgroup/memory/memory.limit_in_bytes': String(256 * MB),
|
|
333
|
+
}),
|
|
334
|
+
),
|
|
335
|
+
).toBe(256 * MB)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('is unknown without a limit', () => {
|
|
339
|
+
expect(
|
|
340
|
+
containerMemoryLimit(reader({ '/sys/fs/cgroup/memory.max': 'max' })),
|
|
341
|
+
).toBeNull()
|
|
342
|
+
expect(
|
|
343
|
+
containerMemoryLimit(
|
|
344
|
+
reader({
|
|
345
|
+
'/sys/fs/cgroup/memory/memory.limit_in_bytes': '9223372036854771712',
|
|
346
|
+
}),
|
|
347
|
+
),
|
|
348
|
+
).toBeNull()
|
|
349
|
+
expect(containerMemoryLimit(reader({}))).toBeNull()
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
describe('tag purge', () => {
|
|
354
|
+
function setup(maxSize = MB) {
|
|
355
|
+
const registry = new LruCacheTagRegistry((cacheType, key) =>
|
|
356
|
+
cacheType === 'route' ? driver.hasItem(key, {}) : false,
|
|
357
|
+
)
|
|
358
|
+
const driver = lruCacheDriver(
|
|
359
|
+
boundedLruOptions(maxSize, (key) => registry.forget('route', key)),
|
|
360
|
+
)
|
|
361
|
+
const route = { storage: createStorage({ driver }), bubbleError: false }
|
|
362
|
+
// What nuxt-multi-cache's afterResponse hook does for a page.
|
|
363
|
+
const store = async (path: string, tags: string[], size = 10) => {
|
|
364
|
+
const key = buildRouteCacheKey(freshEvent(path))
|
|
365
|
+
await route.storage.setItemRaw(
|
|
366
|
+
key,
|
|
367
|
+
encodeRouteCacheItem('x'.repeat(size), {}, 200, -1, 0, false, tags),
|
|
368
|
+
)
|
|
369
|
+
await registry.addCacheTags(key, 'route', tags)
|
|
370
|
+
}
|
|
371
|
+
return { registry, route, store }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
it('evicts route cache entries tagged by Drupal', async () => {
|
|
375
|
+
const { registry, route, store } = setup()
|
|
376
|
+
await store('/de/a', ['abc123', 'menu01'])
|
|
377
|
+
await store('/de/b', ['def456', 'menu01'])
|
|
378
|
+
await store('/de/c', ['ghi789'])
|
|
379
|
+
|
|
380
|
+
// nuxt-multi-cache's own invalidator, as triggered by the
|
|
381
|
+
// /api/multi-cache/purge/tags endpoint Drupal's purger calls. Both with
|
|
382
|
+
// the registry and by reading every entry.
|
|
383
|
+
for (const reg of [registry, null]) {
|
|
384
|
+
const invalidator = new CacheTagInvalidator({ route }, reg)
|
|
385
|
+
invalidator.add(['menu01'])
|
|
386
|
+
await invalidator.invalidate()
|
|
387
|
+
expect(await route.storage.getKeys()).toEqual([
|
|
388
|
+
buildRouteCacheKey(freshEvent('/de/c')),
|
|
389
|
+
])
|
|
390
|
+
}
|
|
391
|
+
expect(registry.size('route')).toBe(1)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('forgets the tags of entries the LRU evicts', async () => {
|
|
395
|
+
const { registry, route, store } = setup(64 * 1024)
|
|
396
|
+
for (let i = 0; i < 200; i++) {
|
|
397
|
+
await store(`/de/page-${i}`, ['N:' + i, 'menu01'], 4096)
|
|
398
|
+
}
|
|
399
|
+
const keys = await route.storage.getKeys()
|
|
400
|
+
expect(keys.length).toBeLessThan(200)
|
|
401
|
+
expect(registry.size('route')).toBe(keys.length)
|
|
402
|
+
expect((await registry.getCacheKeysForTags(['menu01'])).route).toEqual(
|
|
403
|
+
expect.arrayContaining(keys),
|
|
404
|
+
)
|
|
405
|
+
expect(await registry.getCacheKeysForTags(['N:0'])).toEqual({})
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('does not register entries the cache refused', async () => {
|
|
409
|
+
const { registry, store } = setup(64 * 1024)
|
|
410
|
+
await store('/de/huge', ['N:1'], 32 * 1024)
|
|
411
|
+
expect(registry.size('route')).toBe(0)
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('keeps the tags of a replaced entry consistent', async () => {
|
|
415
|
+
const { registry, store } = setup()
|
|
416
|
+
await store('/de/a', ['N:1'])
|
|
417
|
+
await store('/de/a', ['N:2'])
|
|
418
|
+
expect(await registry.getCacheKeysForTags(['N:1'])).toEqual({})
|
|
419
|
+
expect((await registry.getCacheKeysForTags(['N:2'])).route).toHaveLength(1)
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
describe('Cache-Control headers in the default mode', () => {
|
|
424
|
+
const config = resolvePageCacheConfig({})
|
|
425
|
+
|
|
426
|
+
it('turns a cacheable page into a short browser cache', () => {
|
|
427
|
+
const event = renderedEvent(cacheablePage)
|
|
428
|
+
applyCacheControlHeaders(event, config)
|
|
429
|
+
const headers = getResponseHeaders(event)
|
|
430
|
+
expect(headers['cache-control']).toBe('public, max-age=300')
|
|
431
|
+
expect(headers['cache-tag']).toBeUndefined()
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('never lets anything cache a response for a session', () => {
|
|
435
|
+
const event = renderedEvent(cacheablePage, { cookie: 'SSESSabc=1' })
|
|
436
|
+
applyCacheControlHeaders(event, config)
|
|
437
|
+
const headers = getResponseHeaders(event)
|
|
438
|
+
expect(headers['cache-control']).toBe('private, no-store')
|
|
439
|
+
expect(headers['cache-tag']).toBeUndefined()
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
it('keeps uncacheable responses uncacheable', () => {
|
|
443
|
+
const event = renderedEvent((cdn) => cdn.private())
|
|
444
|
+
applyCacheControlHeaders(event, config)
|
|
445
|
+
expect(getResponseHeaders(event)['cache-control']).toBe('private, no-store')
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('does not cache server errors', () => {
|
|
449
|
+
const event = renderedEvent(cacheablePage)
|
|
450
|
+
setResponseStatus(event, 500)
|
|
451
|
+
applyCacheControlHeaders(event, config)
|
|
452
|
+
expect(getResponseHeaders(event)['cache-control']).toBe('private, no-store')
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('marks the SPA shell for a session as private', () => {
|
|
456
|
+
const event = freshEvent('/de/foo', { cookie: 'SSESSabc=1' })
|
|
457
|
+
setResponseHeader(event, 'content-type', 'text/html;charset=utf-8')
|
|
458
|
+
applyCacheControlHeaders(event, config)
|
|
459
|
+
expect(getResponseHeaders(event)['cache-control']).toBe('private, no-store')
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('drops Cache-Tag copied over from an error page render', () => {
|
|
463
|
+
const event = freshEvent('/de/missing')
|
|
464
|
+
setResponseStatus(event, 404)
|
|
465
|
+
setResponseHeader(event, 'cache-control', 'no-cache')
|
|
466
|
+
setResponseHeader(event, 'cache-tag', '4xx-response http_response')
|
|
467
|
+
applyCacheControlHeaders(event, config)
|
|
468
|
+
const headers = getResponseHeaders(event)
|
|
469
|
+
expect(headers['cache-tag']).toBeUndefined()
|
|
470
|
+
expect(headers['cache-control']).toBe('no-cache')
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it('leaves responses without collected cacheability alone', () => {
|
|
474
|
+
const event = freshEvent('/_nuxt/entry.js', { cookie: 'SSESSabc=1' })
|
|
475
|
+
setResponseHeader(event, 'cache-control', 'public,max-age=31536000')
|
|
476
|
+
applyCacheControlHeaders(event, config)
|
|
477
|
+
expect(getResponseHeaders(event)['cache-control']).toBe(
|
|
478
|
+
'public,max-age=31536000',
|
|
479
|
+
)
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('keeps the shared cache headers in the varnish mode', () => {
|
|
483
|
+
const event = renderedEvent(cacheablePage)
|
|
484
|
+
applyCacheControlHeaders(event, resolvePageCacheConfig({ mode: 'varnish' }))
|
|
485
|
+
const headers = getResponseHeaders(event)
|
|
486
|
+
expect(headers['cache-control']).toMatch(/\bs-maxage=31536000\b/)
|
|
487
|
+
expect(headers['cache-tag']).toBe('N:12')
|
|
488
|
+
})
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
describe('route cache finalization', () => {
|
|
492
|
+
const config = resolvePageCacheConfig({ routeMaxAge: 3600 })
|
|
493
|
+
|
|
494
|
+
function eventWithRoute(
|
|
495
|
+
configure: (route: NuxtMultiCacheRouteCacheHelper) => void,
|
|
496
|
+
) {
|
|
497
|
+
const event = freshEvent()
|
|
498
|
+
const route = new NuxtMultiCacheRouteCacheHelper(NOW)
|
|
499
|
+
configure(route)
|
|
500
|
+
event.context.multiCache = { route }
|
|
501
|
+
return { event, route }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
it('clamps a permanent max age to the configured ceiling', () => {
|
|
505
|
+
const { event, route } = eventWithRoute((r) => r.setCacheable())
|
|
506
|
+
expect(route.maxAge).toBe(-1)
|
|
507
|
+
finalizeRouteCache(event, config)
|
|
508
|
+
expect(route.maxAge).toBe(3600)
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
it('keeps a shorter max age', () => {
|
|
512
|
+
const { event, route } = eventWithRoute((r) =>
|
|
513
|
+
r.setCacheable().setMaxAge(60),
|
|
514
|
+
)
|
|
515
|
+
finalizeRouteCache(event, config)
|
|
516
|
+
expect(route.maxAge).toBe(60)
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it('does not store responses that set a cookie', () => {
|
|
520
|
+
const { event, route } = eventWithRoute((r) => r.setCacheable())
|
|
521
|
+
setResponseHeader(event, 'set-cookie', 'foo=bar')
|
|
522
|
+
finalizeRouteCache(event, config)
|
|
523
|
+
expect(route.isCacheable()).toBe(false)
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it('can not be made cacheable once a GraphQL response was uncacheable', () => {
|
|
527
|
+
const { route } = eventWithRoute((r) => r.setUncacheable().setCacheable())
|
|
528
|
+
expect(route.isCacheable()).toBe(false)
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it('never replays cookies from the cache', () => {
|
|
532
|
+
expect(
|
|
533
|
+
alterCachedHeaders({
|
|
534
|
+
'set-cookie': ['a=b'],
|
|
535
|
+
'x-icms-page-cache': 'MISS',
|
|
536
|
+
'content-type': 'text/html',
|
|
537
|
+
}),
|
|
538
|
+
).toEqual({ 'content-type': 'text/html' })
|
|
539
|
+
})
|
|
540
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { normalizeKey } from 'unstorage'
|
|
2
|
+
import type { CacheTagRegistry } from 'nuxt-multi-cache/dist/runtime/types/CacheTagRegistry'
|
|
3
|
+
|
|
4
|
+
type CacheType = 'route' | 'data' | 'component'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A cache tag registry for caches that evict entries on their own (LRU).
|
|
8
|
+
*
|
|
9
|
+
* Without a registry nuxt-multi-cache finds the entries to purge by reading
|
|
10
|
+
* and decoding every cached entry. With one it looks the tags up instead.
|
|
11
|
+
* nuxt-multi-cache's own in-memory registry is never told when an LRU cache
|
|
12
|
+
* evicts an entry, so it would grow forever; this one is: the caches call
|
|
13
|
+
* forget() from their dispose callback, and only keys a cache actually holds
|
|
14
|
+
* are registered.
|
|
15
|
+
*
|
|
16
|
+
* Keys are normalized like unstorage does, so they match the keys the
|
|
17
|
+
* storage driver sees.
|
|
18
|
+
*/
|
|
19
|
+
export class LruCacheTagRegistry implements CacheTagRegistry {
|
|
20
|
+
private tagToKeys = new Map<CacheType, Map<string, Set<string>>>()
|
|
21
|
+
private keyToTags = new Map<CacheType, Map<string, Set<string>>>()
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
private holds: (cacheType: CacheType, key: string) => boolean = () => true,
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
private maps(cacheType: CacheType) {
|
|
28
|
+
if (!this.tagToKeys.has(cacheType)) {
|
|
29
|
+
this.tagToKeys.set(cacheType, new Map())
|
|
30
|
+
this.keyToTags.set(cacheType, new Map())
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
tagToKeys: this.tagToKeys.get(cacheType)!,
|
|
34
|
+
keyToTags: this.keyToTags.get(cacheType)!,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Remove a key, e.g. when the cache evicted it.
|
|
40
|
+
*/
|
|
41
|
+
forget(cacheType: CacheType, rawKey: string) {
|
|
42
|
+
const key = normalizeKey(rawKey)
|
|
43
|
+
const { tagToKeys, keyToTags } = this.maps(cacheType)
|
|
44
|
+
for (const tag of keyToTags.get(key) ?? []) {
|
|
45
|
+
const keys = tagToKeys.get(tag)
|
|
46
|
+
keys?.delete(key)
|
|
47
|
+
if (!keys?.size) {
|
|
48
|
+
tagToKeys.delete(tag)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
keyToTags.delete(key)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Number of registered keys, for tests and monitoring.
|
|
56
|
+
*/
|
|
57
|
+
size(cacheType: CacheType) {
|
|
58
|
+
return this.maps(cacheType).keyToTags.size
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
addCacheTags(rawKey: string, cacheType: CacheType, cacheTags: string[]) {
|
|
62
|
+
const key = normalizeKey(rawKey)
|
|
63
|
+
// An entry the cache refused (larger than maxEntrySize) or evicted
|
|
64
|
+
// right away would never be forgotten.
|
|
65
|
+
if (this.holds(cacheType, key)) {
|
|
66
|
+
const { tagToKeys, keyToTags } = this.maps(cacheType)
|
|
67
|
+
const tags = keyToTags.get(key) ?? new Set<string>()
|
|
68
|
+
keyToTags.set(key, tags)
|
|
69
|
+
for (const tag of cacheTags) {
|
|
70
|
+
tags.add(tag)
|
|
71
|
+
const keys = tagToKeys.get(tag) ?? new Set<string>()
|
|
72
|
+
tagToKeys.set(tag, keys)
|
|
73
|
+
keys.add(key)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return Promise.resolve()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getCacheKeysForTags(tags: string[]) {
|
|
80
|
+
const result: Partial<Record<CacheType, string[]>> = {}
|
|
81
|
+
for (const [cacheType, tagToKeys] of this.tagToKeys) {
|
|
82
|
+
const keys = new Set(
|
|
83
|
+
tags.flatMap((tag) => [...(tagToKeys.get(tag) ?? [])]),
|
|
84
|
+
)
|
|
85
|
+
if (keys.size) {
|
|
86
|
+
result[cacheType] = [...keys]
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return Promise.resolve(result)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
removeTags(tags: string[]) {
|
|
93
|
+
// The purged entries are removed from their cache right before this,
|
|
94
|
+
// which already forgot them through the dispose callback. Drop what is
|
|
95
|
+
// left, e.g. keys of a cache without that callback.
|
|
96
|
+
for (const [cacheType, tagToKeys] of this.tagToKeys) {
|
|
97
|
+
for (const tag of tags) {
|
|
98
|
+
for (const key of [...(tagToKeys.get(tag) ?? [])]) {
|
|
99
|
+
this.forget(cacheType, key)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return Promise.resolve()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
removeCacheItem(cacheType: CacheType, key: string | string[]) {
|
|
107
|
+
for (const item of Array.isArray(key) ? key : [key]) {
|
|
108
|
+
this.forget(cacheType, item)
|
|
109
|
+
}
|
|
110
|
+
return Promise.resolve()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
purgeCache(cacheType: CacheType) {
|
|
114
|
+
this.tagToKeys.delete(cacheType)
|
|
115
|
+
this.keyToTags.delete(cacheType)
|
|
116
|
+
return Promise.resolve()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
purgeEverything() {
|
|
120
|
+
this.tagToKeys.clear()
|
|
121
|
+
this.keyToTags.clear()
|
|
122
|
+
return Promise.resolve()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
2
|
+
import { getRequestHeader } from 'h3'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Drupal session cookies are named SESS<hash> (http) or SSESS<hash> (https).
|
|
6
|
+
*/
|
|
7
|
+
const SESSION_COOKIE = /(?:^|;)\s*S?SESS[\w-]+=/
|
|
8
|
+
|
|
9
|
+
export function hasDrupalSession(cookieHeader: string | undefined | null) {
|
|
10
|
+
return !!cookieHeader && SESSION_COOKIE.test(cookieHeader)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Requests with a Drupal session bypass all caches.
|
|
15
|
+
*
|
|
16
|
+
* Kept free of Nitro imports: the Vue app's SSR build imports it as its
|
|
17
|
+
* server options (modules/pageCache.ts).
|
|
18
|
+
*/
|
|
19
|
+
export function enabledForRequest(event: H3Event) {
|
|
20
|
+
return Promise.resolve(!hasDrupalSession(getRequestHeader(event, 'cookie')))
|
|
21
|
+
}
|