@iqual/nuxt-icms 1.6.0-rc.4 → 1.6.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/components/Drupal/Webform/index.vue +7 -7
- package/config/__tests__/multiCache.spec.ts +67 -0
- package/config/__tests__/stubs/nuxt-multi-cache-config.ts +6 -0
- package/config/blokkli.ts +2 -1
- package/config/multiCache.ts +1 -1
- package/package.json +2 -2
- package/patches/@blokkli+editor+2.0.0-alpha.13.patch +164 -0
- package/patches/nuxt-graphql-middleware+5.4.0.patch +232 -0
- package/scripts/update-icms.ts +28 -6
- package/server/__tests__/graphqlMiddleware.serverOptions.spec.ts +170 -0
- package/server/graphqlMiddleware.serverOptions.ts +26 -10
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
@error="console.log"
|
|
9
9
|
noValidate
|
|
10
10
|
>
|
|
11
|
-
|
|
12
|
-
<DesignSystemAtomsButton
|
|
13
|
-
v-if="debug"
|
|
14
|
-
@click="console.log(schema.safeParse(state))"
|
|
11
|
+
|
|
12
|
+
<DesignSystemAtomsButton
|
|
13
|
+
v-if="debug"
|
|
14
|
+
@click="console.log(schema.safeParse(state))"
|
|
15
15
|
label="Debug Schema"
|
|
16
16
|
/>
|
|
17
17
|
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
v-model="token"
|
|
25
25
|
v-if="formElement?.metadata?.key === 'actions'"
|
|
26
26
|
/> -->
|
|
27
|
-
|
|
27
|
+
|
|
28
28
|
<component
|
|
29
29
|
v-if="formElement?.metadata?.type"
|
|
30
30
|
:is="getComponent(formElement?.metadata?.type)"
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
:debug="debug"
|
|
35
35
|
/>
|
|
36
36
|
</div>
|
|
37
|
-
<DesignSystemAtomsButton
|
|
38
|
-
v-if="debug"
|
|
37
|
+
<DesignSystemAtomsButton
|
|
38
|
+
v-if="debug"
|
|
39
39
|
@click="console.log(schema.safeParse(state))"
|
|
40
40
|
label="Debug Schema"
|
|
41
41
|
:buttonStyle="'focus'"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { createEvent, getResponseHeaders } from 'h3'
|
|
3
|
+
import { IncomingMessage, ServerResponse } from 'node-mock-http'
|
|
4
|
+
import { NuxtMultiCacheCDNHelper } from 'nuxt-multi-cache/dist/runtime/helpers/CDNHelper.js'
|
|
5
|
+
import multiCache from '../multiCache'
|
|
6
|
+
|
|
7
|
+
type CDNHelper = InstanceType<typeof NuxtMultiCacheCDNHelper>
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Guards the CDN caching contract between the Nuxt SSR responses and
|
|
11
|
+
* Varnish (ICMS-86, regressed in ICMS-329, fixed in ICMS-689).
|
|
12
|
+
*
|
|
13
|
+
* Varnish (open-source) only understands Cache-Control — not the RFC 9213
|
|
14
|
+
* CDN-Cache-Control header. The VCL derives the TTL from s-maxage, detects
|
|
15
|
+
* uncacheable responses via `no-cache|no-store|private`, and maps Cache-Tag
|
|
16
|
+
* to xkey for tag-based purging. If these header names change, SSR pages
|
|
17
|
+
* silently degrade to Varnish's 120s default TTL.
|
|
18
|
+
*/
|
|
19
|
+
describe('multiCache CDN configuration', () => {
|
|
20
|
+
it('emits caching directives in the Cache-Control header', () => {
|
|
21
|
+
expect(multiCache.cdn).toMatchObject({
|
|
22
|
+
enabled: true,
|
|
23
|
+
cacheControlHeader: 'Cache-Control',
|
|
24
|
+
cacheTagHeader: 'Cache-Tag',
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('CDN helper with the configured header names', () => {
|
|
30
|
+
function applyToFreshEvent(configure: (helper: CDNHelper) => void) {
|
|
31
|
+
const event = createEvent(new IncomingMessage(), new ServerResponse())
|
|
32
|
+
const helper = new NuxtMultiCacheCDNHelper(
|
|
33
|
+
Math.floor(Date.now() / 1000),
|
|
34
|
+
multiCache.cdn!.cacheControlHeader,
|
|
35
|
+
multiCache.cdn!.cacheTagHeader,
|
|
36
|
+
)
|
|
37
|
+
configure(helper)
|
|
38
|
+
helper.applyToEvent(event)
|
|
39
|
+
return getResponseHeaders(event)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
it('writes s-maxage and cache tags for a cacheable response', () => {
|
|
43
|
+
const headers = applyToFreshEvent((helper) => {
|
|
44
|
+
helper
|
|
45
|
+
.public()
|
|
46
|
+
.setNumeric('sharedMaxAge', 31536000)
|
|
47
|
+
.addTags(['nuxt', 'N:12'])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// The VCL derives the TTL from s-maxage; directive order is irrelevant.
|
|
51
|
+
expect(headers['cache-control']).toMatch(/\bs-maxage=31536000\b/)
|
|
52
|
+
expect(headers['cache-control']).toMatch(/\bpublic\b/)
|
|
53
|
+
expect(headers['cache-control']).not.toMatch(/private|no-cache|no-store/)
|
|
54
|
+
expect(headers['cache-tag']).toBe('nuxt N:12')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('marks uncacheable responses as private', () => {
|
|
58
|
+
const headers = applyToFreshEvent((helper) => {
|
|
59
|
+
helper.private()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Varnish's uncacheable-response detection matches
|
|
63
|
+
// `no-cache|no-store|private` on Cache-Control.
|
|
64
|
+
expect(headers['cache-control']).toMatch(/private/)
|
|
65
|
+
expect(headers['cache-tag']).toBeUndefined()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Stub for the virtual module '#nuxt-multi-cache/config' that is normally
|
|
2
|
+
// generated at build time by nuxt-multi-cache. Tests pass header names
|
|
3
|
+
// explicitly to the CDN helper, so these defaults are never used.
|
|
4
|
+
export const cdnCacheControlHeader = ''
|
|
5
|
+
export const cdnCacheTagHeader = ''
|
|
6
|
+
export const cdnEnabled = true
|
package/config/blokkli.ts
CHANGED
|
@@ -72,6 +72,7 @@ export default defineNuxtConfigProperty<'blokkli'>((ctx) => {
|
|
|
72
72
|
},
|
|
73
73
|
pattern: [
|
|
74
74
|
'./components/Paragraph/**/*.{js,ts,vue}',
|
|
75
|
+
'./components/Blokkli/**/*.{js,ts,vue}',
|
|
75
76
|
join(currentLayerDir, './components/Blokkli/**/*.{js,ts,vue}'),
|
|
76
77
|
],
|
|
77
78
|
globalOptions: {
|
|
@@ -385,4 +386,4 @@ export default defineNuxtConfigProperty<'blokkli'>((ctx) => {
|
|
|
385
386
|
// current page language.
|
|
386
387
|
forceDefaultLanguage: false,
|
|
387
388
|
}
|
|
388
|
-
})
|
|
389
|
+
})
|
package/config/multiCache.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iqual/nuxt-icms",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "ICMS Base",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"nitropack": "~2.13.4",
|
|
33
33
|
"nuxt": "~4.4.8",
|
|
34
34
|
"nuxt-easy-texts": "~2.1.2",
|
|
35
|
-
"nuxt-graphql-middleware": "
|
|
35
|
+
"nuxt-graphql-middleware": "5.4.0",
|
|
36
36
|
"nuxt-language-negotiation": "~2.0.2",
|
|
37
37
|
"nuxt-maplibre": "~1.2.2",
|
|
38
38
|
"nuxt-multi-cache": "~4.0.3",
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
diff --git a/node_modules/@blokkli/editor/dist/module.mjs b/node_modules/@blokkli/editor/dist/module.mjs
|
|
2
|
+
index 0ff0d3b..5329e9f 100644
|
|
3
|
+
--- a/node_modules/@blokkli/editor/dist/module.mjs
|
|
4
|
+
+++ b/node_modules/@blokkli/editor/dist/module.mjs
|
|
5
|
+
@@ -271,16 +271,31 @@ class CollectedBlockFile extends CollectedFile {
|
|
6
|
+
}
|
|
7
|
+
class BlockCollector extends Collector {
|
|
8
|
+
patterns;
|
|
9
|
+
+ positivePatterns;
|
|
10
|
+
+ negativePatterns;
|
|
11
|
+
+ // Files that define the same block bundle (or fragment name) as a file in a
|
|
12
|
+
+ // higher-priority Nuxt layer. They are kept here instead of in `files` so
|
|
13
|
+
+ // they can be re-activated when the overriding file is removed.
|
|
14
|
+
+ shadowed = /* @__PURE__ */ new Map();
|
|
15
|
+
constructor(helper) {
|
|
16
|
+
super(helper);
|
|
17
|
+
- this.patterns = (helper.options.pattern || []).map((pattern) => {
|
|
18
|
+
+ this.patterns = (helper.options.pattern || []).map((raw) => {
|
|
19
|
+
+ // Support negated glob patterns (e.g. "!**/components/Blokkli/Text/**")
|
|
20
|
+
+ // to exclude components from collection.
|
|
21
|
+
+ const isNegated = raw.startsWith("!");
|
|
22
|
+
+ const pattern = isNegated ? raw.slice(1) : raw;
|
|
23
|
+
+ let resolved = pattern;
|
|
24
|
+
if (pattern.startsWith("/")) {
|
|
25
|
+
- return pattern;
|
|
26
|
+
+ resolved = pattern;
|
|
27
|
+
} else if (pattern.startsWith(".")) {
|
|
28
|
+
- return helper.resolvers.src.resolve(pattern);
|
|
29
|
+
+ resolved = helper.resolvers.src.resolve(pattern);
|
|
30
|
+
+ } else {
|
|
31
|
+
+ resolved = resolveAlias(pattern);
|
|
32
|
+
}
|
|
33
|
+
- return resolveAlias(pattern);
|
|
34
|
+
+ return isNegated ? "!" + resolved : resolved;
|
|
35
|
+
});
|
|
36
|
+
+ this.positivePatterns = this.patterns.filter((v) => !v.startsWith("!"));
|
|
37
|
+
+ this.negativePatterns = this.patterns.filter((v) => v.startsWith("!")).map((v) => v.slice(1));
|
|
38
|
+
}
|
|
39
|
+
async init() {
|
|
40
|
+
const files = await resolveFiles(
|
|
41
|
+
@@ -295,6 +310,90 @@ class BlockCollector extends Collector {
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
await Promise.all(promises);
|
|
45
|
+
+ this.resolveLayerOverrides();
|
|
46
|
+
+ }
|
|
47
|
+
+ /**
|
|
48
|
+
+ * Determine the Nuxt layer priority of a file.
|
|
49
|
+
+ *
|
|
50
|
+
+ * Lower value = higher priority. The app/project layer is index 0, extended
|
|
51
|
+
+ * layers follow in the order they are defined. Files that can't be matched
|
|
52
|
+
+ * to a layer get the lowest priority.
|
|
53
|
+
+ */
|
|
54
|
+
+ getLayerPriority(filePath) {
|
|
55
|
+
+ const layers = this.helper.nuxt.options._layers || [];
|
|
56
|
+
+ let priority = layers.length;
|
|
57
|
+
+ let longestMatch = -1;
|
|
58
|
+
+ for (let i = 0; i < layers.length; i++) {
|
|
59
|
+
+ const config = layers[i].config || {};
|
|
60
|
+
+ const dirs = [config.srcDir, config.rootDir, layers[i].cwd];
|
|
61
|
+
+ for (const dir of dirs) {
|
|
62
|
+
+ if (!dir) {
|
|
63
|
+
+ continue;
|
|
64
|
+
+ }
|
|
65
|
+
+ const prefix = dir.endsWith("/") ? dir : dir + "/";
|
|
66
|
+
+ if (filePath.startsWith(prefix) && dir.length > longestMatch) {
|
|
67
|
+
+ priority = i;
|
|
68
|
+
+ longestMatch = dir.length;
|
|
69
|
+
+ }
|
|
70
|
+
+ }
|
|
71
|
+
+ }
|
|
72
|
+
+ return priority;
|
|
73
|
+
+ }
|
|
74
|
+
+ /**
|
|
75
|
+
+ * Resolve collisions where multiple collected files define the same block
|
|
76
|
+
+ * bundle (or fragment name): the file from the highest-priority Nuxt layer
|
|
77
|
+
+ * wins, the others are shadowed. Files in the same layer are left untouched
|
|
78
|
+
+ * so genuine duplicates still surface as errors.
|
|
79
|
+
+ *
|
|
80
|
+
+ * @returns Whether the set of active files has changed.
|
|
81
|
+
+ */
|
|
82
|
+
+ resolveLayerOverrides() {
|
|
83
|
+
+ const previouslyShadowed = this.shadowed;
|
|
84
|
+
+ this.shadowed = /* @__PURE__ */ new Map();
|
|
85
|
+
+ for (const [filePath, file] of previouslyShadowed) {
|
|
86
|
+
+ if (!this.files.has(filePath)) {
|
|
87
|
+
+ this.files.set(filePath, file);
|
|
88
|
+
+ }
|
|
89
|
+
+ }
|
|
90
|
+
+ const byIdentifier = /* @__PURE__ */ new Map();
|
|
91
|
+
+ for (const file of this.files.values()) {
|
|
92
|
+
+ if (!file.definition || !file.identifier) {
|
|
93
|
+
+ continue;
|
|
94
|
+
+ }
|
|
95
|
+
+ const group = byIdentifier.get(file.identifier);
|
|
96
|
+
+ if (group) {
|
|
97
|
+
+ group.push(file);
|
|
98
|
+
+ } else {
|
|
99
|
+
+ byIdentifier.set(file.identifier, [file]);
|
|
100
|
+
+ }
|
|
101
|
+
+ }
|
|
102
|
+
+ for (const group of byIdentifier.values()) {
|
|
103
|
+
+ if (group.length < 2) {
|
|
104
|
+
+ continue;
|
|
105
|
+
+ }
|
|
106
|
+
+ const priorities = group.map((file) => this.getLayerPriority(file.filePath));
|
|
107
|
+
+ const winningPriority = Math.min(...priorities);
|
|
108
|
+
+ group.forEach((file, index) => {
|
|
109
|
+
+ if (priorities[index] > winningPriority) {
|
|
110
|
+
+ this.shadowed.set(file.filePath, file);
|
|
111
|
+
+ this.files.delete(file.filePath);
|
|
112
|
+
+ }
|
|
113
|
+
+ });
|
|
114
|
+
+ }
|
|
115
|
+
+ if (previouslyShadowed.size !== this.shadowed.size) {
|
|
116
|
+
+ return true;
|
|
117
|
+
+ }
|
|
118
|
+
+ for (const filePath of this.shadowed.keys()) {
|
|
119
|
+
+ if (!previouslyShadowed.has(filePath)) {
|
|
120
|
+
+ return true;
|
|
121
|
+
+ }
|
|
122
|
+
+ }
|
|
123
|
+
+ return false;
|
|
124
|
+
+ }
|
|
125
|
+
+ async handleWatchEvent(event, filePath) {
|
|
126
|
+
+ const result = await super.handleWatchEvent(event, filePath);
|
|
127
|
+
+ const overridesChanged = this.resolveLayerOverrides();
|
|
128
|
+
+ return { hasChanged: result.hasChanged || overridesChanged };
|
|
129
|
+
}
|
|
130
|
+
runHooks() {
|
|
131
|
+
return this.helper.nuxt.hooks.callHook("blokkli:alter-blocks", {
|
|
132
|
+
@@ -348,7 +447,10 @@ class BlockCollector extends Collector {
|
|
133
|
+
if (!filePath.endsWith(".vue")) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
- if (!micromatch.isMatch(filePath, this.patterns)) {
|
|
137
|
+
+ if (!micromatch.isMatch(filePath, this.positivePatterns)) {
|
|
138
|
+
+ return false;
|
|
139
|
+
+ }
|
|
140
|
+
+ if (this.negativePatterns.length && micromatch.isMatch(filePath, this.negativePatterns)) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
if (isEditComponent(filePath)) {
|
|
144
|
+
@@ -470,7 +572,19 @@ class IconCollector extends Collector {
|
|
145
|
+
this.helper.paths.srcDir,
|
|
146
|
+
"**/icon-blokkli-*.svg"
|
|
147
|
+
);
|
|
148
|
+
- const allFiles = [...filesModule, ...filesApp];
|
|
149
|
+
+ // Also scan all Nuxt layer directories for icon SVGs.
|
|
150
|
+
+ const layerFiles = [];
|
|
151
|
+
+ const layers = this.helper.nuxt.options._layers || [];
|
|
152
|
+
+ for (const layer of layers) {
|
|
153
|
+
+ if (layer.config && layer.config.srcDir && layer.config.srcDir !== this.helper.paths.srcDir) {
|
|
154
|
+
+ const files = await resolveFiles(
|
|
155
|
+
+ layer.config.srcDir,
|
|
156
|
+
+ "**/icon-blokkli-*.svg"
|
|
157
|
+
+ );
|
|
158
|
+
+ layerFiles.push(...files);
|
|
159
|
+
+ }
|
|
160
|
+
+ }
|
|
161
|
+
+ const allFiles = [...filesModule, ...filesApp, ...layerFiles];
|
|
162
|
+
await Promise.all(allFiles.map((filePath) => this.addFile(filePath)));
|
|
163
|
+
}
|
|
164
|
+
runHooks() {
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
diff --git a/node_modules/nuxt-graphql-middleware/dist/module.mjs b/node_modules/nuxt-graphql-middleware/dist/module.mjs
|
|
2
|
+
index 8396abf..62f5068 100644
|
|
3
|
+
--- a/node_modules/nuxt-graphql-middleware/dist/module.mjs
|
|
4
|
+
+++ b/node_modules/nuxt-graphql-middleware/dist/module.mjs
|
|
5
|
+
@@ -215,12 +215,115 @@ class Collector {
|
|
6
|
+
*/
|
|
7
|
+
fragments = [];
|
|
8
|
+
isInitialised = false;
|
|
9
|
+
+ /**
|
|
10
|
+
+ * The definition keys (kind:name) currently synced to the generator, per
|
|
11
|
+
+ * file path.
|
|
12
|
+
+ */
|
|
13
|
+
+ syncedDefinitionKeys = /* @__PURE__ */ new Map();
|
|
14
|
+
+ /**
|
|
15
|
+
+ * File paths whose contents have changed since the last generator sync.
|
|
16
|
+
+ */
|
|
17
|
+
+ dirtyFiles = /* @__PURE__ */ new Set();
|
|
18
|
+
async reset() {
|
|
19
|
+
this.files.clear();
|
|
20
|
+
+ this.syncedDefinitionKeys.clear();
|
|
21
|
+
+ this.dirtyFiles.clear();
|
|
22
|
+
this.generator.reset();
|
|
23
|
+
this.operationTimestamps.clear();
|
|
24
|
+
this.rpcItems.clear();
|
|
25
|
+
}
|
|
26
|
+
+ /**
|
|
27
|
+
+ * Determine the Nuxt layer priority of a file.
|
|
28
|
+
+ *
|
|
29
|
+
+ * Lower value = higher priority. The app/project layer is index 0, extended
|
|
30
|
+
+ * layers follow in the order they are defined. Documents provided via hooks
|
|
31
|
+
+ * get the highest priority. Files that can't be matched to a layer get the
|
|
32
|
+
+ * lowest priority.
|
|
33
|
+
+ */
|
|
34
|
+
+ getLayerPriority(filePath) {
|
|
35
|
+
+ if (filePath.startsWith("hook:")) {
|
|
36
|
+
+ return -1;
|
|
37
|
+
+ }
|
|
38
|
+
+ const layers = this.helper.nuxt.options._layers || [];
|
|
39
|
+
+ let priority = layers.length;
|
|
40
|
+
+ let longestMatch = -1;
|
|
41
|
+
+ for (let i = 0; i < layers.length; i++) {
|
|
42
|
+
+ const config = layers[i].config || {};
|
|
43
|
+
+ const dirs = [config.srcDir, config.rootDir, layers[i].cwd];
|
|
44
|
+
+ for (const dir of dirs) {
|
|
45
|
+
+ if (!dir) {
|
|
46
|
+
+ continue;
|
|
47
|
+
+ }
|
|
48
|
+
+ const prefix = dir.endsWith("/") ? dir : dir + "/";
|
|
49
|
+
+ if (filePath.startsWith(prefix) && dir.length > longestMatch) {
|
|
50
|
+
+ priority = i;
|
|
51
|
+
+ longestMatch = dir.length;
|
|
52
|
+
+ }
|
|
53
|
+
+ }
|
|
54
|
+
+ }
|
|
55
|
+
+ return priority;
|
|
56
|
+
+ }
|
|
57
|
+
+ /**
|
|
58
|
+
+ * Sync all collected files to the generator, resolving cross-layer
|
|
59
|
+
+ * definition collisions: when multiple files define a fragment or operation
|
|
60
|
+
+ * with the same name, the definition from the highest-priority Nuxt layer
|
|
61
|
+
+ * (the app/project first, then extended layers in order) wins and the
|
|
62
|
+
+ * shadowed definitions are excluded from the documents passed to the
|
|
63
|
+
+ * generator. This makes it possible to override a fragment or operation
|
|
64
|
+
+ * from a layer in the app or in a higher-priority layer. Definitions with
|
|
65
|
+
+ * the same layer priority are left untouched (existing behaviour).
|
|
66
|
+
+ */
|
|
67
|
+
+ syncGenerator() {
|
|
68
|
+
+ const owners = /* @__PURE__ */ new Map();
|
|
69
|
+
+ for (const [filePath, file] of this.files) {
|
|
70
|
+
+ const priority = this.getLayerPriority(filePath);
|
|
71
|
+
+ for (const def of file.parsed.definitions) {
|
|
72
|
+
+ if (def.kind !== "FragmentDefinition" && def.kind !== "OperationDefinition") {
|
|
73
|
+
+ continue;
|
|
74
|
+
+ }
|
|
75
|
+
+ const name = def.name?.value;
|
|
76
|
+
+ if (!name) {
|
|
77
|
+
+ continue;
|
|
78
|
+
+ }
|
|
79
|
+
+ const key = def.kind + ":" + name;
|
|
80
|
+
+ const owner = owners.get(key);
|
|
81
|
+
+ if (!owner || priority < owner.priority) {
|
|
82
|
+
+ owners.set(key, { priority });
|
|
83
|
+
+ }
|
|
84
|
+
+ }
|
|
85
|
+
+ }
|
|
86
|
+
+ for (const [filePath, file] of this.files) {
|
|
87
|
+
+ const priority = this.getLayerPriority(filePath);
|
|
88
|
+
+ const definitions = file.parsed.definitions.filter((def) => {
|
|
89
|
+
+ if (def.kind !== "FragmentDefinition" && def.kind !== "OperationDefinition") {
|
|
90
|
+
+ return true;
|
|
91
|
+
+ }
|
|
92
|
+
+ const name = def.name?.value;
|
|
93
|
+
+ if (!name) {
|
|
94
|
+
+ return true;
|
|
95
|
+
+ }
|
|
96
|
+
+ const owner = owners.get(def.kind + ":" + name);
|
|
97
|
+
+ return !owner || owner.priority >= priority;
|
|
98
|
+
+ });
|
|
99
|
+
+ const keys = definitions.map((def) => def.kind + ":" + (def.name?.value || "")).join("|");
|
|
100
|
+
+ const previousKeys = this.syncedDefinitionKeys.get(filePath);
|
|
101
|
+
+ const documentNode = definitions.length === file.parsed.definitions.length ? file.parsed : { ...file.parsed, definitions };
|
|
102
|
+
+ if (previousKeys === void 0) {
|
|
103
|
+
+ this.generator.add({ filePath, documentNode });
|
|
104
|
+
+ this.syncedDefinitionKeys.set(filePath, keys);
|
|
105
|
+
+ } else if (previousKeys !== keys || this.dirtyFiles.has(filePath)) {
|
|
106
|
+
+ this.generator.update({ filePath, documentNode });
|
|
107
|
+
+ this.syncedDefinitionKeys.set(filePath, keys);
|
|
108
|
+
+ }
|
|
109
|
+
+ }
|
|
110
|
+
+ for (const filePath of [...this.syncedDefinitionKeys.keys()]) {
|
|
111
|
+
+ if (!this.files.has(filePath)) {
|
|
112
|
+
+ this.generator.remove(filePath);
|
|
113
|
+
+ this.syncedDefinitionKeys.delete(filePath);
|
|
114
|
+
+ }
|
|
115
|
+
+ }
|
|
116
|
+
+ this.dirtyFiles.clear();
|
|
117
|
+
+ }
|
|
118
|
+
async updateSchema(schema) {
|
|
119
|
+
this.schema = schema;
|
|
120
|
+
this.generator.updateSchema(schema);
|
|
121
|
+
@@ -436,10 +539,10 @@ class Collector {
|
|
122
|
+
const exists = this.hookDocuments.has(fullIdentifier);
|
|
123
|
+
this.hookDocuments.set(fullIdentifier, source);
|
|
124
|
+
if (exists && this.isInitialised) {
|
|
125
|
+
- this.generator.update({
|
|
126
|
+
- filePath: fullIdentifier,
|
|
127
|
+
- document: source
|
|
128
|
+
- });
|
|
129
|
+
+ const file = new CollectedFile(fullIdentifier, source, false);
|
|
130
|
+
+ this.files.set(fullIdentifier, file);
|
|
131
|
+
+ this.dirtyFiles.add(fullIdentifier);
|
|
132
|
+
+ this.syncGenerator();
|
|
133
|
+
await this.buildState();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
@@ -460,23 +563,16 @@ class Collector {
|
|
137
|
+
const filePath = this.helper.paths.nuxtConfig;
|
|
138
|
+
const file = new CollectedFile(filePath, nuxtConfigDocuments, false);
|
|
139
|
+
this.files.set(filePath, file);
|
|
140
|
+
- this.generator.add({
|
|
141
|
+
- filePath,
|
|
142
|
+
- documentNode: file.parsed
|
|
143
|
+
- });
|
|
144
|
+
}
|
|
145
|
+
const hookDocuments = [...this.hookDocuments.entries()];
|
|
146
|
+
hookDocuments.forEach(([identifier, source]) => {
|
|
147
|
+
const file = new CollectedFile(identifier, source, false);
|
|
148
|
+
this.files.set(identifier, file);
|
|
149
|
+
- this.generator.add({
|
|
150
|
+
- filePath: identifier,
|
|
151
|
+
- documentNode: file.parsed
|
|
152
|
+
- });
|
|
153
|
+
});
|
|
154
|
+
for (const filePath of this.hookFiles) {
|
|
155
|
+
await this.addFile(filePath);
|
|
156
|
+
}
|
|
157
|
+
+ this.syncGenerator();
|
|
158
|
+
await this.buildState();
|
|
159
|
+
if (!this.helper.isPrepare) {
|
|
160
|
+
logger.success("All GraphQL documents are valid.");
|
|
161
|
+
@@ -495,10 +591,6 @@ class Collector {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
this.files.set(filePath, file);
|
|
165
|
+
- this.generator.add({
|
|
166
|
+
- filePath,
|
|
167
|
+
- documentNode: file.parsed
|
|
168
|
+
- });
|
|
169
|
+
return file;
|
|
170
|
+
}
|
|
171
|
+
matchesPatternOrExists(filePath) {
|
|
172
|
+
@@ -509,7 +601,11 @@ class Collector {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
const result = await this.addFile(filePath);
|
|
176
|
+
- return !!result;
|
|
177
|
+
+ if (!result) {
|
|
178
|
+
+ return false;
|
|
179
|
+
+ }
|
|
180
|
+
+ this.syncGenerator();
|
|
181
|
+
+ return true;
|
|
182
|
+
}
|
|
183
|
+
async handleChange(filePath) {
|
|
184
|
+
if (!this.matchesPatternOrExists(filePath)) {
|
|
185
|
+
@@ -524,10 +620,8 @@ class Collector {
|
|
186
|
+
if (!needsUpdate) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
- this.generator.update({
|
|
190
|
+
- filePath,
|
|
191
|
+
- documentNode: file.parsed
|
|
192
|
+
- });
|
|
193
|
+
+ this.dirtyFiles.add(filePath);
|
|
194
|
+
+ this.syncGenerator();
|
|
195
|
+
} catch {
|
|
196
|
+
return this.handleUnlink(filePath);
|
|
197
|
+
}
|
|
198
|
+
@@ -539,7 +633,7 @@ class Collector {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
this.files.delete(filePath);
|
|
202
|
+
- this.generator.remove(filePath);
|
|
203
|
+
+ this.syncGenerator();
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
handleUnlinkDir(folderPath) {
|
|
207
|
+
@@ -1128,7 +1222,24 @@ class ModuleHelper {
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
matchesImportPattern(filePath) {
|
|
211
|
+
- return micromatch.isMatch(filePath, this.options.autoImportPatterns) || this.options.autoImportPatterns.includes(filePath);
|
|
212
|
+
+ // micromatch.isMatch with an array returns true if *any* pattern matches.
|
|
213
|
+
+ // A negated pattern (e.g. "!node_modules") matches every path that does
|
|
214
|
+
+ // not equal it, which would make every file match. Split the patterns and
|
|
215
|
+
+ // apply proper include/exclude semantics instead.
|
|
216
|
+
+ const positive = this.options.autoImportPatterns.filter(
|
|
217
|
+
+ (v) => !v.startsWith("!")
|
|
218
|
+
+ );
|
|
219
|
+
+ const negative = this.options.autoImportPatterns.filter((v) => v.startsWith("!")).map((v) => v.slice(1));
|
|
220
|
+
+ if (this.options.autoImportPatterns.includes(filePath)) {
|
|
221
|
+
+ return true;
|
|
222
|
+
+ }
|
|
223
|
+
+ if (!micromatch.isMatch(filePath, positive)) {
|
|
224
|
+
+ return false;
|
|
225
|
+
+ }
|
|
226
|
+
+ if (negative.length && micromatch.isMatch(filePath, negative)) {
|
|
227
|
+
+ return false;
|
|
228
|
+
+ }
|
|
229
|
+
+ return true;
|
|
230
|
+
}
|
|
231
|
+
addAlias(name, path) {
|
|
232
|
+
this.nuxt.options.alias[name] = path;
|
package/scripts/update-icms.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { readFile } from 'node:fs/promises'
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Updates all installed @iqual packages to the given NPM dist-tag,
|
|
6
|
-
* e.g. `rc` for testing ICMS release candidates.
|
|
6
|
+
* e.g. `rc` for testing ICMS release candidates. When `latest` points to a
|
|
7
|
+
* newer version than the given dist-tag (e.g. the stable release following
|
|
8
|
+
* the release candidates), the newer version wins.
|
|
7
9
|
*
|
|
8
10
|
* Usage (from the frontend root): bun run ./node_modules/@iqual/nuxt-icms/scripts/update-icms.ts <dist-tag>
|
|
9
11
|
*/
|
|
@@ -20,10 +22,30 @@ if (!distTag) {
|
|
|
20
22
|
|
|
21
23
|
const pkg = JSON.parse(await readFile('./package.json', 'utf-8'))
|
|
22
24
|
|
|
25
|
+
const resolve = async (name: string): Promise<string> => {
|
|
26
|
+
const response = await fetch(`https://registry.npmjs.org/${name}`)
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
fail(`Failed to fetch dist-tags for ${name} (${response.status})`)
|
|
29
|
+
}
|
|
30
|
+
const tags: Record<string, string> = (await response.json())['dist-tags']
|
|
31
|
+
const tagged = tags[distTag]
|
|
32
|
+
if (!tagged) {
|
|
33
|
+
fail(`No \`${distTag}\` dist-tag found for ${name}`)
|
|
34
|
+
}
|
|
35
|
+
return tags.latest && Bun.semver.order(tags.latest, tagged) > 0
|
|
36
|
+
? tags.latest
|
|
37
|
+
: tagged
|
|
38
|
+
}
|
|
39
|
+
|
|
23
40
|
const pick = (dependencies?: Record<string, string>) =>
|
|
24
|
-
|
|
25
|
-
.
|
|
26
|
-
|
|
41
|
+
Promise.all(
|
|
42
|
+
Object.entries(dependencies ?? {})
|
|
43
|
+
.filter(
|
|
44
|
+
([name, constraint]) =>
|
|
45
|
+
name.startsWith('@iqual/') && !constraint.startsWith('workspace:'),
|
|
46
|
+
)
|
|
47
|
+
.map(async ([name]) => `${name}@^${await resolve(name)}`),
|
|
48
|
+
)
|
|
27
49
|
|
|
28
50
|
const add = (packages: string[], flags: string[] = []) => {
|
|
29
51
|
if (packages.length === 0) return
|
|
@@ -34,5 +56,5 @@ const add = (packages: string[], flags: string[] = []) => {
|
|
|
34
56
|
}
|
|
35
57
|
}
|
|
36
58
|
|
|
37
|
-
add(pick(pkg.dependencies))
|
|
38
|
-
add(pick(pkg.devDependencies), ['--dev'])
|
|
59
|
+
add(await pick(pkg.dependencies))
|
|
60
|
+
add(await pick(pkg.devDependencies), ['--dev'])
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
createEvent,
|
|
4
|
+
getResponseHeaders,
|
|
5
|
+
appendResponseHeader,
|
|
6
|
+
setResponseStatus,
|
|
7
|
+
} from 'h3'
|
|
8
|
+
import type { H3Event } from 'h3'
|
|
9
|
+
import { IncomingMessage, ServerResponse } from 'node-mock-http'
|
|
10
|
+
import { NuxtMultiCacheCDNHelper } from 'nuxt-multi-cache/dist/runtime/helpers/CDNHelper.js'
|
|
11
|
+
import multiCache from '../../config/multiCache'
|
|
12
|
+
|
|
13
|
+
vi.mock('#nuxt-language-negotiation/config', () => ({
|
|
14
|
+
defaultLangcode: 'de',
|
|
15
|
+
langcodes: ['de', 'fr', 'it', 'en'],
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
vi.mock('#shared/request', () => ({
|
|
19
|
+
buildProxyRequestHeaders: () => ({}),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
// Nitro auto-imports in the module under test, provided as globals: the h3
|
|
23
|
+
// helpers are the real ones, useCDNHeaders mirrors nuxt-multi-cache's server
|
|
24
|
+
// util using the real CDN helper and the real multiCache configuration — so
|
|
25
|
+
// these tests cover the full chain from config to emitted header values.
|
|
26
|
+
vi.stubGlobal('appendResponseHeader', appendResponseHeader)
|
|
27
|
+
vi.stubGlobal('setResponseStatus', setResponseStatus)
|
|
28
|
+
vi.stubGlobal(
|
|
29
|
+
'useCDNHeaders',
|
|
30
|
+
(
|
|
31
|
+
cb: (helper: InstanceType<typeof NuxtMultiCacheCDNHelper>) => void,
|
|
32
|
+
event: H3Event,
|
|
33
|
+
) => {
|
|
34
|
+
const helper = new NuxtMultiCacheCDNHelper(
|
|
35
|
+
Math.floor(Date.now() / 1000),
|
|
36
|
+
multiCache.cdn!.cacheControlHeader,
|
|
37
|
+
multiCache.cdn!.cacheTagHeader,
|
|
38
|
+
)
|
|
39
|
+
cb(helper)
|
|
40
|
+
helper.applyToEvent(event)
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const serverOptions = (await import('../graphqlMiddleware.serverOptions'))
|
|
45
|
+
.default
|
|
46
|
+
|
|
47
|
+
function freshEvent() {
|
|
48
|
+
return createEvent(new IncomingMessage(), new ServerResponse())
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function drupalResponse({
|
|
52
|
+
expires = '-1',
|
|
53
|
+
purgeCacheTags = 'graphql_response node:12 http_response',
|
|
54
|
+
data = { route: { label: 'Test' } } as Record<string, unknown>,
|
|
55
|
+
}: {
|
|
56
|
+
expires?: string | null
|
|
57
|
+
purgeCacheTags?: string
|
|
58
|
+
data?: Record<string, unknown>
|
|
59
|
+
} = {}) {
|
|
60
|
+
const headers = new Headers({
|
|
61
|
+
'purge-cache-tags': purgeCacheTags,
|
|
62
|
+
'x-nuxt-cache-tags': 'N:12 h5ej',
|
|
63
|
+
})
|
|
64
|
+
if (expires !== null) {
|
|
65
|
+
headers.set('x-nuxt-expires', expires)
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
headers,
|
|
69
|
+
_data: { data, errors: [] },
|
|
70
|
+
} as never
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const clientContext = { client: { env: 'client' } } as never
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Guards the CDN headers on the /api/graphql/* server routes, used by
|
|
77
|
+
* SPA-style client navigation (ICMS-86; the pass-through was lost in the
|
|
78
|
+
* nuxt-graphql-middleware v5 upgrade and restored with ICMS-689). Without
|
|
79
|
+
* them, Varnish caches these responses for its 120s default TTL and cannot
|
|
80
|
+
* purge them by cache tag.
|
|
81
|
+
*/
|
|
82
|
+
describe('onServerResponse', () => {
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
vi.restoreAllMocks()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('sets Cache-Control and the raw Drupal cache tags for cacheable queries', () => {
|
|
88
|
+
const event = freshEvent()
|
|
89
|
+
|
|
90
|
+
serverOptions.onServerResponse!(
|
|
91
|
+
event,
|
|
92
|
+
drupalResponse(),
|
|
93
|
+
'query',
|
|
94
|
+
'route',
|
|
95
|
+
clientContext,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const headers = getResponseHeaders(event)
|
|
99
|
+
expect(headers['cache-control']).toMatch(/\bs-maxage=31536000\b/)
|
|
100
|
+
expect(headers['cache-control']).toMatch(/\bpublic\b/)
|
|
101
|
+
// The raw purge tags purge Varnish; the encoded nuxt tags must not
|
|
102
|
+
// appear — they only concern the internal data cache.
|
|
103
|
+
expect(headers['cache-tag']).toBe(
|
|
104
|
+
'graphql_response node:12 http_response nuxt:api:graphql',
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('marks uncacheable responses (x-nuxt-expires: 0) as private', () => {
|
|
109
|
+
const event = freshEvent()
|
|
110
|
+
|
|
111
|
+
serverOptions.onServerResponse!(
|
|
112
|
+
event,
|
|
113
|
+
drupalResponse({ expires: '0' }),
|
|
114
|
+
'query',
|
|
115
|
+
'route',
|
|
116
|
+
clientContext,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const headers = getResponseHeaders(event)
|
|
120
|
+
expect(headers['cache-control']).toMatch(/\bprivate\b/)
|
|
121
|
+
expect(headers['cache-control']).not.toMatch(/s-maxage/)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('marks mutations as private', () => {
|
|
125
|
+
const event = freshEvent()
|
|
126
|
+
|
|
127
|
+
serverOptions.onServerResponse!(
|
|
128
|
+
event,
|
|
129
|
+
drupalResponse(),
|
|
130
|
+
'mutation',
|
|
131
|
+
'submitForm',
|
|
132
|
+
clientContext,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
expect(getResponseHeaders(event)['cache-control']).toMatch(/\bprivate\b/)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('marks responses containing Drupal messages as private', () => {
|
|
139
|
+
const event = freshEvent()
|
|
140
|
+
|
|
141
|
+
serverOptions.onServerResponse!(
|
|
142
|
+
event,
|
|
143
|
+
drupalResponse({
|
|
144
|
+
data: { messengerMessages: [{ type: 'error', safe: 'Nope' }] },
|
|
145
|
+
}),
|
|
146
|
+
'query',
|
|
147
|
+
'route',
|
|
148
|
+
clientContext,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
expect(getResponseHeaders(event)['cache-control']).toMatch(/\bprivate\b/)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('onServerError', () => {
|
|
156
|
+
it('marks error responses as private with status 500', () => {
|
|
157
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
158
|
+
const event = freshEvent()
|
|
159
|
+
|
|
160
|
+
serverOptions.onServerError!(
|
|
161
|
+
event,
|
|
162
|
+
new Error('backend down') as never,
|
|
163
|
+
'query',
|
|
164
|
+
'route',
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
expect(event.node.res.statusCode).toBe(500)
|
|
168
|
+
expect(getResponseHeaders(event)['cache-control']).toMatch(/\bprivate\b/)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineGraphqlServerOptions } from 'nuxt-graphql-middleware/server-options'
|
|
2
2
|
import { extractCacheability } from '../server/utils/cacheability'
|
|
3
3
|
import { defaultLangcode, langcodes } from '#nuxt-language-negotiation/config'
|
|
4
|
-
import type
|
|
4
|
+
import { MAX_AGE, type GraphqlCacheability } from '../server/helpers'
|
|
5
5
|
import { buildProxyRequestHeaders } from '#shared/request'
|
|
6
6
|
|
|
7
7
|
export default defineGraphqlServerOptions<{
|
|
@@ -14,7 +14,7 @@ export default defineGraphqlServerOptions<{
|
|
|
14
14
|
const clientContextLanguage = context?.client?.language
|
|
15
15
|
if (
|
|
16
16
|
clientContextLanguage &&
|
|
17
|
-
langcodes.includes(clientContextLanguage as
|
|
17
|
+
langcodes.includes(clientContextLanguage as (typeof langcodes)[number])
|
|
18
18
|
) {
|
|
19
19
|
return `${config.backendUrl}/${clientContextLanguage}/graphql`
|
|
20
20
|
}
|
|
@@ -31,13 +31,7 @@ export default defineGraphqlServerOptions<{
|
|
|
31
31
|
},
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
|
-
onServerResponse(
|
|
35
|
-
event,
|
|
36
|
-
graphqlResponse,
|
|
37
|
-
_operation,
|
|
38
|
-
_operationName,
|
|
39
|
-
context,
|
|
40
|
-
) {
|
|
34
|
+
onServerResponse(event, graphqlResponse, operation, operationName, context) {
|
|
41
35
|
// Pass the set-cookie header from the GraphQL response to the client.
|
|
42
36
|
const setCookie = graphqlResponse.headers.getSetCookie()
|
|
43
37
|
|
|
@@ -58,6 +52,27 @@ export default defineGraphqlServerOptions<{
|
|
|
58
52
|
cacheability.isCacheable = false
|
|
59
53
|
}
|
|
60
54
|
|
|
55
|
+
// Set the CDN headers (Cache-Control, Cache-Tag) on this API route's
|
|
56
|
+
// response, so that Varnish caches client-side navigation requests with
|
|
57
|
+
// the correct TTL and purges them by cache tag (ICMS-86/ICMS-689 —
|
|
58
|
+
// originally introduced in PR #71, lost in the nuxt-graphql-middleware
|
|
59
|
+
// v5 upgrade). The raw Drupal tags (tagsCdn) are used here; the encoded
|
|
60
|
+
// tags (tagsNuxt) only concern the internal data cache.
|
|
61
|
+
useCDNHeaders((cdn) => {
|
|
62
|
+
if (operation === 'query' && operationName && cacheability.isCacheable) {
|
|
63
|
+
cdn
|
|
64
|
+
.public()
|
|
65
|
+
.setNumeric('sharedMaxAge', cacheability.sharedMaxAge)
|
|
66
|
+
.set('staleIfError', MAX_AGE.ONE_DAY)
|
|
67
|
+
.set('staleWhileRevalidate', MAX_AGE.TWO_HOURS)
|
|
68
|
+
.set('mustRevalidate', true)
|
|
69
|
+
.addTags(cacheability.tagsCdn)
|
|
70
|
+
.addTags(['nuxt:api:graphql'])
|
|
71
|
+
} else {
|
|
72
|
+
cdn.private()
|
|
73
|
+
}
|
|
74
|
+
}, event)
|
|
75
|
+
|
|
61
76
|
const isServerRequest = context?.client?.env === 'server'
|
|
62
77
|
|
|
63
78
|
// This is only provided when the request originates from SSR.
|
|
@@ -77,10 +92,11 @@ export default defineGraphqlServerOptions<{
|
|
|
77
92
|
},
|
|
78
93
|
onServerError(event, error) {
|
|
79
94
|
setResponseStatus(event, 500)
|
|
95
|
+
useCDNHeaders((cdn) => cdn.private(), event)
|
|
80
96
|
console.log('onServerError', error)
|
|
81
97
|
return {
|
|
82
98
|
data: null,
|
|
83
99
|
errors: [],
|
|
84
100
|
}
|
|
85
101
|
},
|
|
86
|
-
})
|
|
102
|
+
})
|