druxt 0.25.0-dev.20260927110413 → 0.25.0-dev.20260928061526
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 +39 -0
- package/dist/druxt.esm.js +130 -35
- package/dist/druxt.ssr.js +130 -35
- package/dist/server-middleware/cacheClear.mjs +39 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -81,10 +81,49 @@ Translated routes also need a `decoupled_router` patch; see the
|
|
|
81
81
|
| `endpoint` | `string` | `'/jsonapi'` | The JSON:API endpoint path. |
|
|
82
82
|
| `proxy.api` | `boolean` | `false` | Proxy API requests via Nuxt ([guide](https://druxtjs.org/how-to/proxy)). |
|
|
83
83
|
| `proxy.files` | `boolean\|string` | `false` | Proxy Drupal files. A string sets the site. |
|
|
84
|
+
| `cache` | `boolean\|object` | `{}` | The server cache, see below. `false` turns it off. |
|
|
84
85
|
|
|
85
86
|
See the [Nuxt module API](https://druxtjs.org/api/packages/druxt/nuxt) for the full
|
|
86
87
|
options list.
|
|
87
88
|
|
|
89
|
+
## Server cache
|
|
90
|
+
|
|
91
|
+
In production, the server keeps the JSON:API index and menus between requests
|
|
92
|
+
for as long as Drupal's `Cache-Control` header allows. Drupal sets that header
|
|
93
|
+
from the page cache maximum age (Administration > Configuration > Development >
|
|
94
|
+
Performance). At the default of 0, nothing is kept. A request with an
|
|
95
|
+
`Authorization` header or a Drupal session cookie never reads or fills the
|
|
96
|
+
cache. The cache is off under `nuxt dev`.
|
|
97
|
+
|
|
98
|
+
| Option | Type | Default | Description |
|
|
99
|
+
| --------------------- | -------- | ------------------- | ------------------------------------------------------------------- |
|
|
100
|
+
| `cache.ttl` | `number` | | Seconds a response may be kept at most. `0` keeps nothing. |
|
|
101
|
+
| `cache.secret` | `string` | | Turns on `POST /_druxt/cache/clear`. |
|
|
102
|
+
| `cache.sessionCookie` | `string` | `'S?SESS[0-9a-f]+'` | A pattern for the session cookie name, for a proxy that renames it. |
|
|
103
|
+
|
|
104
|
+
Clear the cache from the app with `this.$store.dispatch('druxt/clearCache')`,
|
|
105
|
+
or from Drupal by sending `POST /_druxt/cache/clear` with the secret in an
|
|
106
|
+
`X-Druxt-Secret` header. Each Nuxt process has its own cache, so a site with
|
|
107
|
+
several processes clears each one.
|
|
108
|
+
|
|
109
|
+
### Clearing from Drupal with Purge
|
|
110
|
+
|
|
111
|
+
The [Purge](https://www.drupal.org/project/purge) module can send that request
|
|
112
|
+
whenever content changes. Enable `purge`, `purge_purger_http`,
|
|
113
|
+
`purge_queuer_coretags` and a processor such as `purge_processor_lateruntime`,
|
|
114
|
+
then add the bundled HTTP purger:
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
drush p:purger-add httpbundled
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Give it the method `POST`, the Nuxt host and port, the path
|
|
121
|
+
`/_druxt/cache/clear`, a header `X-Druxt-Secret` with the secret, and the
|
|
122
|
+
invalidation type `tag`. One save then costs one request, whatever it
|
|
123
|
+
invalidates, and a request refused for a wrong secret is sent again with the
|
|
124
|
+
next batch. Keep the secret out of exported configuration: set it from
|
|
125
|
+
`settings.php` as a `$config` override.
|
|
126
|
+
|
|
88
127
|
## DruxtClient
|
|
89
128
|
|
|
90
129
|
The client is the communication layer between your application and Drupal's
|
package/dist/druxt.esm.js
CHANGED
|
@@ -10,6 +10,8 @@ import Vue from 'vue';
|
|
|
10
10
|
|
|
11
11
|
const SCOPES = Symbol.for("druxt.processCache");
|
|
12
12
|
const scopes = globalThis[SCOPES] || (globalThis[SCOPES] = new Map());
|
|
13
|
+
const GENERATION = Symbol.for("druxt.processCacheGeneration");
|
|
14
|
+
const generation = globalThis[GENERATION] || (globalThis[GENERATION] = { value: 0 });
|
|
13
15
|
const credentialed = new WeakSet();
|
|
14
16
|
const watched = new WeakSet();
|
|
15
17
|
const runtime = { isServer: () => typeof window === "undefined" };
|
|
@@ -94,7 +96,9 @@ const processCache = (scope, { axios, ttl, sessionCookie } = {}) => {
|
|
|
94
96
|
consumers.set(consumer, new Map());
|
|
95
97
|
const entries = consumers.get(consumer);
|
|
96
98
|
const cap = (seconds) => typeof ttl === "number" ? Math.min(seconds, ttl) : seconds;
|
|
99
|
+
const taken = generation.value;
|
|
97
100
|
return {
|
|
101
|
+
generation: taken,
|
|
98
102
|
get(key) {
|
|
99
103
|
const entry = entries.get(key);
|
|
100
104
|
if (!entry)
|
|
@@ -103,7 +107,9 @@ const processCache = (scope, { axios, ttl, sessionCookie } = {}) => {
|
|
|
103
107
|
return void 0;
|
|
104
108
|
return entry.value;
|
|
105
109
|
},
|
|
106
|
-
set(key, value, seconds) {
|
|
110
|
+
set(key, value, seconds, since) {
|
|
111
|
+
if (since !== void 0 && since !== generation.value)
|
|
112
|
+
return value;
|
|
107
113
|
if (cap(seconds) > 0)
|
|
108
114
|
entries.set(key, { value, seconds: cap(seconds), created: Date.now() });
|
|
109
115
|
else
|
|
@@ -112,6 +118,13 @@ const processCache = (scope, { axios, ttl, sessionCookie } = {}) => {
|
|
|
112
118
|
}
|
|
113
119
|
};
|
|
114
120
|
};
|
|
121
|
+
const resetProcessCache = (scope) => {
|
|
122
|
+
if (scope)
|
|
123
|
+
scopes.delete(scope);
|
|
124
|
+
else
|
|
125
|
+
scopes.clear();
|
|
126
|
+
generation.value += 1;
|
|
127
|
+
};
|
|
115
128
|
|
|
116
129
|
const indexCache = new WeakMap();
|
|
117
130
|
const lifetimes = new WeakMap();
|
|
@@ -152,10 +165,21 @@ class DruxtClient {
|
|
|
152
165
|
const indexKey = JSON.stringify([baseUrl, this.options.endpoint, this.options.jsonapiResourceConfig]);
|
|
153
166
|
this.indexKey = indexKey;
|
|
154
167
|
if (!indexCache.has(this.axios))
|
|
155
|
-
indexCache.set(this.axios, { index: {}, requests: {} });
|
|
168
|
+
indexCache.set(this.axios, { index: {}, requests: {}, generation: 0 });
|
|
156
169
|
const cache = indexCache.get(this.axios);
|
|
170
|
+
this.indexCache = cache;
|
|
157
171
|
this.indexRequests = cache.requests;
|
|
158
172
|
this.index = cache.index[indexKey] || (cache.index[indexKey] = {});
|
|
173
|
+
this.cacheGeneration = 0;
|
|
174
|
+
}
|
|
175
|
+
clearCache() {
|
|
176
|
+
for (const prefix of Object.keys(this.index))
|
|
177
|
+
delete this.index[prefix];
|
|
178
|
+
for (const key of Object.keys(this.indexRequests))
|
|
179
|
+
delete this.indexRequests[key];
|
|
180
|
+
this.indexCache.generation += 1;
|
|
181
|
+
resetProcessCache();
|
|
182
|
+
this.cacheGeneration += 1;
|
|
159
183
|
}
|
|
160
184
|
processCache(scope) {
|
|
161
185
|
if (!this.options.cache)
|
|
@@ -299,17 +323,24 @@ class DruxtClient {
|
|
|
299
323
|
}
|
|
300
324
|
if (!(this.index || {})[prefix]) {
|
|
301
325
|
const key = [this.indexKey, prefix || ""].join(":");
|
|
326
|
+
const before = this.processCache("index");
|
|
327
|
+
const since = before ? before.generation : void 0;
|
|
328
|
+
const started = this.indexCache.generation;
|
|
302
329
|
const request = this.indexRequests[key] || (this.indexRequests[key] = this.fetchIndex(prefix));
|
|
303
|
-
let
|
|
330
|
+
let fetched;
|
|
304
331
|
try {
|
|
305
|
-
|
|
332
|
+
fetched = await request;
|
|
306
333
|
} finally {
|
|
307
334
|
if (this.indexRequests[key] === request)
|
|
308
335
|
delete this.indexRequests[key];
|
|
309
336
|
}
|
|
337
|
+
if (started !== this.indexCache.generation) {
|
|
338
|
+
return resource ? fetched.index[resource] || false : fetched.index;
|
|
339
|
+
}
|
|
340
|
+
this.index[prefix] = fetched.index;
|
|
310
341
|
const shared = this.processCache("index");
|
|
311
|
-
if (shared
|
|
312
|
-
shared.set(key,
|
|
342
|
+
if (shared)
|
|
343
|
+
shared.set(key, fetched.index, fetched.lifetime, since);
|
|
313
344
|
}
|
|
314
345
|
return resource ? this.index[prefix][resource] || false : this.index[prefix];
|
|
315
346
|
}
|
|
@@ -352,8 +383,7 @@ class DruxtClient {
|
|
|
352
383
|
};
|
|
353
384
|
}
|
|
354
385
|
}
|
|
355
|
-
this.
|
|
356
|
-
return Math.min(...documents.map((document) => this.cacheLifetime(document)));
|
|
386
|
+
return { index, lifetime: Math.min(...documents.map((document) => this.cacheLifetime(document))) };
|
|
357
387
|
}
|
|
358
388
|
async getRelated(type, id, related, query, prefix = "") {
|
|
359
389
|
if (!id || !type || !related) {
|
|
@@ -399,8 +429,30 @@ class DruxtClient {
|
|
|
399
429
|
}
|
|
400
430
|
}
|
|
401
431
|
|
|
432
|
+
const safeEqual = (input, secret) => {
|
|
433
|
+
let diff = input.length ^ secret.length;
|
|
434
|
+
for (let i = 0; i < input.length; i++) {
|
|
435
|
+
diff |= input.charCodeAt(i) ^ secret.charCodeAt(i % secret.length);
|
|
436
|
+
}
|
|
437
|
+
return diff === 0;
|
|
438
|
+
};
|
|
439
|
+
const cacheClearHandler = (secret) => (req, res) => {
|
|
440
|
+
if (req.method !== "POST") {
|
|
441
|
+
res.statusCode = 405;
|
|
442
|
+
res.setHeader("Allow", "POST");
|
|
443
|
+
return res.end();
|
|
444
|
+
}
|
|
445
|
+
if (!safeEqual(String(req.headers["x-druxt-secret"] || ""), String(secret))) {
|
|
446
|
+
res.statusCode = 401;
|
|
447
|
+
return res.end();
|
|
448
|
+
}
|
|
449
|
+
resetProcessCache();
|
|
450
|
+
res.statusCode = 204;
|
|
451
|
+
res.end();
|
|
452
|
+
};
|
|
453
|
+
|
|
402
454
|
var name = "druxt";
|
|
403
|
-
var version = "0.25.0-dev.
|
|
455
|
+
var version = "0.25.0-dev.20260928061526";
|
|
404
456
|
var description = "The Fully Decoupled Drupal Framework for Nuxt.js.";
|
|
405
457
|
var keywords = [
|
|
406
458
|
"cms",
|
|
@@ -459,7 +511,7 @@ var dependencies = {
|
|
|
459
511
|
};
|
|
460
512
|
var devDependencies = {
|
|
461
513
|
"@storybook/addon-docs": "^6.5.16",
|
|
462
|
-
"druxt-test-utils": "1.0.0-dev.
|
|
514
|
+
"druxt-test-utils": "1.0.0-dev.20260928061526"
|
|
463
515
|
};
|
|
464
516
|
var peerDependencies = {
|
|
465
517
|
axios: "0.28.0",
|
|
@@ -505,6 +557,12 @@ const DruxtNuxtModule = async function(moduleOptions = {}) {
|
|
|
505
557
|
if (options.cache !== false) {
|
|
506
558
|
options.cache = this.options.dev ? false : { ...options.cache };
|
|
507
559
|
}
|
|
560
|
+
const { secret, ...cache } = options.cache || {};
|
|
561
|
+
if (options.cache)
|
|
562
|
+
options.cache = cache;
|
|
563
|
+
if (options.cache && secret) {
|
|
564
|
+
this.addServerMiddleware({ path: "/_druxt/cache/clear", handler: cacheClearHandler(secret) });
|
|
565
|
+
}
|
|
508
566
|
this.options.baseUrl = options.baseUrl = options.baseUrl.endsWith("/") ? options.baseUrl.slice(0, -1) : options.baseUrl;
|
|
509
567
|
this.options.endpoint = options.endpoint = options.endpoint.startsWith("/") ? options.endpoint : `/${options.endpoint}`;
|
|
510
568
|
const druxt = new DruxtClient(options.baseUrl, {
|
|
@@ -636,16 +694,45 @@ const dehydrateResources = ({ commit, queryObject, resources, prefix }) => {
|
|
|
636
694
|
return { id: data.id, type: data.type };
|
|
637
695
|
});
|
|
638
696
|
};
|
|
697
|
+
const collectionHash = (query) => {
|
|
698
|
+
if (!query)
|
|
699
|
+
return "_default";
|
|
700
|
+
const queryObject = getDrupalJsonApiParams(query).getQueryObject();
|
|
701
|
+
return md5(JSON.stringify({ ...queryObject, fields: {}, include: [] }));
|
|
702
|
+
};
|
|
703
|
+
const flush = (tree, key, keys, leaf) => {
|
|
704
|
+
const byKey = tree[key];
|
|
705
|
+
if (!byKey)
|
|
706
|
+
return;
|
|
707
|
+
if (!keys.length && leaf === void 0)
|
|
708
|
+
return Vue.delete(tree, key);
|
|
709
|
+
for (const k of keys.length ? keys : Object.keys(byKey)) {
|
|
710
|
+
if (!byKey[k])
|
|
711
|
+
continue;
|
|
712
|
+
if (leaf !== void 0)
|
|
713
|
+
Vue.delete(byKey[k], leaf);
|
|
714
|
+
else
|
|
715
|
+
Vue.delete(byKey, k);
|
|
716
|
+
}
|
|
717
|
+
};
|
|
639
718
|
const DruxtStore = ({ store }) => {
|
|
640
719
|
if (typeof store === "undefined") {
|
|
641
720
|
throw new TypeError("Vuex store not found.");
|
|
642
721
|
}
|
|
643
722
|
const namespace = "druxt";
|
|
644
723
|
const inFlight = new Map();
|
|
724
|
+
const generation = { value: 0 };
|
|
725
|
+
const flushInFlight = () => {
|
|
726
|
+
inFlight.clear();
|
|
727
|
+
generation.value += 1;
|
|
728
|
+
};
|
|
645
729
|
const share = (key, request) => {
|
|
646
730
|
if (!inFlight.has(key)) {
|
|
647
|
-
const clear = () => inFlight.delete(key);
|
|
648
731
|
const promise = request();
|
|
732
|
+
const clear = () => {
|
|
733
|
+
if (inFlight.get(key) === promise)
|
|
734
|
+
inFlight.delete(key);
|
|
735
|
+
};
|
|
649
736
|
inFlight.set(key, promise);
|
|
650
737
|
promise.then(clear, clear);
|
|
651
738
|
}
|
|
@@ -700,31 +787,33 @@ const DruxtStore = ({ store }) => {
|
|
|
700
787
|
resource = merge(state.resources[type][id][prefix] || {}, resource, { arrayMerge: (dst, src) => src });
|
|
701
788
|
Vue.set(state.resources[type][id], prefix, resource);
|
|
702
789
|
},
|
|
703
|
-
flushCollection(state, { type, hash, prefix }) {
|
|
790
|
+
flushCollection(state, { type, hash, query, prefix } = {}) {
|
|
791
|
+
flushInFlight();
|
|
704
792
|
if (!type)
|
|
705
|
-
Vue.set(state, "collections", {});
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
else if (type && hash && !prefix)
|
|
709
|
-
Vue.set(state.collections[type], hash, {});
|
|
710
|
-
else if (type && hash && prefix)
|
|
711
|
-
Vue.set(state.collections[type][hash], prefix, {});
|
|
793
|
+
return Vue.set(state, "collections", {});
|
|
794
|
+
const key = hash !== void 0 ? hash : query !== void 0 ? collectionHash(query) : void 0;
|
|
795
|
+
flush(state.collections, type, key !== void 0 ? [key] : [], prefix);
|
|
712
796
|
},
|
|
713
|
-
flushResource(state, { type, id, prefix }) {
|
|
797
|
+
flushResource(state, { type, id, prefix } = {}) {
|
|
798
|
+
flushInFlight();
|
|
714
799
|
if (!type)
|
|
715
|
-
Vue.set(state, "resources", {});
|
|
716
|
-
|
|
717
|
-
Vue.set(state.resources, type, {});
|
|
718
|
-
else if (type && id && !prefix)
|
|
719
|
-
Vue.set(state.resources[type], id, {});
|
|
720
|
-
else if (type && id && prefix)
|
|
721
|
-
Vue.set(state.resources[type][id], prefix, {});
|
|
800
|
+
return Vue.set(state, "resources", {});
|
|
801
|
+
flush(state.resources, type, id !== void 0 ? [id] : [], prefix);
|
|
722
802
|
}
|
|
723
803
|
},
|
|
724
804
|
actions: {
|
|
805
|
+
clearCache({ commit }) {
|
|
806
|
+
if (this.$druxt && typeof this.$druxt.clearCache === "function")
|
|
807
|
+
this.$druxt.clearCache();
|
|
808
|
+
commit("flushCollection", {});
|
|
809
|
+
commit("flushResource", {});
|
|
810
|
+
for (const mutation of ["druxt/views/flushResults", "druxtMenu/flushEntities", "druxtRouter/flushRoutes"]) {
|
|
811
|
+
if (this._mutations[mutation])
|
|
812
|
+
commit(mutation, {}, { root: true });
|
|
813
|
+
}
|
|
814
|
+
},
|
|
725
815
|
async getCollection({ commit, state }, { type, query, prefix, bypassCache = false }) {
|
|
726
|
-
const
|
|
727
|
-
const hash = query ? md5(JSON.stringify({ ...queryObject, fields: {}, include: [] })) : "_default";
|
|
816
|
+
const hash = collectionHash(query);
|
|
728
817
|
if (!bypassCache && ((state.collections[type] || {})[hash] || {})[prefix]) {
|
|
729
818
|
const cached = state.collections[type][hash][prefix];
|
|
730
819
|
const hydrate = (o) => (((state.resources[o.type] || {})[o.id] || {})[prefix] || {}).data;
|
|
@@ -738,10 +827,12 @@ const DruxtStore = ({ store }) => {
|
|
|
738
827
|
};
|
|
739
828
|
}
|
|
740
829
|
}
|
|
741
|
-
const key = JSON.stringify(["collection", prefix, type, hash,
|
|
830
|
+
const key = JSON.stringify(["collection", prefix, type, hash, getDrupalJsonApiParams(query).getQueryObject()]);
|
|
742
831
|
return share(key, async () => {
|
|
832
|
+
const since = generation.value;
|
|
743
833
|
const collection = await this.$druxt.getCollection(type, query, prefix);
|
|
744
|
-
|
|
834
|
+
if (since === generation.value)
|
|
835
|
+
commit("addCollection", { collection: { ...collection }, type, hash, prefix });
|
|
745
836
|
return collection;
|
|
746
837
|
});
|
|
747
838
|
},
|
|
@@ -755,7 +846,7 @@ const DruxtStore = ({ store }) => {
|
|
|
755
846
|
queryObject.fields[type] = Array.from(new Set([...fields2, ...includes])).filter((s) => s).join(",");
|
|
756
847
|
}
|
|
757
848
|
let included = [];
|
|
758
|
-
if (queryObject.include && storedResource) {
|
|
849
|
+
if (queryObject.include && storedResource && !bypassCache) {
|
|
759
850
|
const resources = await Promise.all(queryObject.include.split(",").filter((s) => Object.keys(storedResource.data.relationships || {}).includes(s)).map((key) => {
|
|
760
851
|
let { data } = storedResource.data.relationships[key];
|
|
761
852
|
data = Array.isArray(data) ? data : [data];
|
|
@@ -779,7 +870,7 @@ const DruxtStore = ({ store }) => {
|
|
|
779
870
|
}
|
|
780
871
|
const isFull = typeof (queryObject.fields || {})[type] !== "string";
|
|
781
872
|
let fields = isFull ? true : (queryObject.fields || {})[type];
|
|
782
|
-
if (storedResource && !isFull && fields) {
|
|
873
|
+
if (storedResource && !isFull && fields && !bypassCache) {
|
|
783
874
|
const queryFields = fields.split(",");
|
|
784
875
|
const resourceFields = [
|
|
785
876
|
...Object.keys(((storedResource || {}).data || {}).attributes || {}),
|
|
@@ -790,18 +881,22 @@ const DruxtStore = ({ store }) => {
|
|
|
790
881
|
queryObject.fields[type] = (missingFields || []).join(",") || void 0;
|
|
791
882
|
}
|
|
792
883
|
let resource;
|
|
884
|
+
const since = generation.value;
|
|
793
885
|
if (bypassCache || !storedResource || fields) {
|
|
794
886
|
try {
|
|
795
887
|
const key = JSON.stringify(["resource", prefix, type, id, queryObject]);
|
|
796
888
|
resource = await share(key, async () => {
|
|
889
|
+
const started = generation.value;
|
|
797
890
|
const response = await this.$druxt.getResource(type, id, getDrupalJsonApiParams(queryObject), prefix);
|
|
798
|
-
|
|
891
|
+
if (started === generation.value)
|
|
892
|
+
commit("addResource", { prefix, resource: { ...response } });
|
|
799
893
|
return response;
|
|
800
894
|
});
|
|
801
895
|
} catch (e) {
|
|
802
896
|
}
|
|
803
897
|
}
|
|
804
|
-
const
|
|
898
|
+
const stored = ((state.resources[type] || {})[id] || {})[prefix];
|
|
899
|
+
const result = { ...since === generation.value && stored ? stored : resource || stored };
|
|
805
900
|
if (queryObject.include && ((resource || {}).included || (storedResource || {}).included)) {
|
|
806
901
|
included = [
|
|
807
902
|
...(resource || {}).included || [],
|
package/dist/druxt.ssr.js
CHANGED
|
@@ -23,6 +23,8 @@ const Vue__default = /*#__PURE__*/_interopDefaultLegacy(Vue);
|
|
|
23
23
|
|
|
24
24
|
const SCOPES = Symbol.for("druxt.processCache");
|
|
25
25
|
const scopes = globalThis[SCOPES] || (globalThis[SCOPES] = new Map());
|
|
26
|
+
const GENERATION = Symbol.for("druxt.processCacheGeneration");
|
|
27
|
+
const generation = globalThis[GENERATION] || (globalThis[GENERATION] = { value: 0 });
|
|
26
28
|
const credentialed = new WeakSet();
|
|
27
29
|
const watched = new WeakSet();
|
|
28
30
|
const runtime = { isServer: () => typeof window === "undefined" };
|
|
@@ -107,7 +109,9 @@ const processCache = (scope, { axios, ttl, sessionCookie } = {}) => {
|
|
|
107
109
|
consumers.set(consumer, new Map());
|
|
108
110
|
const entries = consumers.get(consumer);
|
|
109
111
|
const cap = (seconds) => typeof ttl === "number" ? Math.min(seconds, ttl) : seconds;
|
|
112
|
+
const taken = generation.value;
|
|
110
113
|
return {
|
|
114
|
+
generation: taken,
|
|
111
115
|
get(key) {
|
|
112
116
|
const entry = entries.get(key);
|
|
113
117
|
if (!entry)
|
|
@@ -116,7 +120,9 @@ const processCache = (scope, { axios, ttl, sessionCookie } = {}) => {
|
|
|
116
120
|
return void 0;
|
|
117
121
|
return entry.value;
|
|
118
122
|
},
|
|
119
|
-
set(key, value, seconds) {
|
|
123
|
+
set(key, value, seconds, since) {
|
|
124
|
+
if (since !== void 0 && since !== generation.value)
|
|
125
|
+
return value;
|
|
120
126
|
if (cap(seconds) > 0)
|
|
121
127
|
entries.set(key, { value, seconds: cap(seconds), created: Date.now() });
|
|
122
128
|
else
|
|
@@ -125,6 +131,13 @@ const processCache = (scope, { axios, ttl, sessionCookie } = {}) => {
|
|
|
125
131
|
}
|
|
126
132
|
};
|
|
127
133
|
};
|
|
134
|
+
const resetProcessCache = (scope) => {
|
|
135
|
+
if (scope)
|
|
136
|
+
scopes.delete(scope);
|
|
137
|
+
else
|
|
138
|
+
scopes.clear();
|
|
139
|
+
generation.value += 1;
|
|
140
|
+
};
|
|
128
141
|
|
|
129
142
|
const indexCache = new WeakMap();
|
|
130
143
|
const lifetimes = new WeakMap();
|
|
@@ -165,10 +178,21 @@ class DruxtClient {
|
|
|
165
178
|
const indexKey = JSON.stringify([baseUrl, this.options.endpoint, this.options.jsonapiResourceConfig]);
|
|
166
179
|
this.indexKey = indexKey;
|
|
167
180
|
if (!indexCache.has(this.axios))
|
|
168
|
-
indexCache.set(this.axios, { index: {}, requests: {} });
|
|
181
|
+
indexCache.set(this.axios, { index: {}, requests: {}, generation: 0 });
|
|
169
182
|
const cache = indexCache.get(this.axios);
|
|
183
|
+
this.indexCache = cache;
|
|
170
184
|
this.indexRequests = cache.requests;
|
|
171
185
|
this.index = cache.index[indexKey] || (cache.index[indexKey] = {});
|
|
186
|
+
this.cacheGeneration = 0;
|
|
187
|
+
}
|
|
188
|
+
clearCache() {
|
|
189
|
+
for (const prefix of Object.keys(this.index))
|
|
190
|
+
delete this.index[prefix];
|
|
191
|
+
for (const key of Object.keys(this.indexRequests))
|
|
192
|
+
delete this.indexRequests[key];
|
|
193
|
+
this.indexCache.generation += 1;
|
|
194
|
+
resetProcessCache();
|
|
195
|
+
this.cacheGeneration += 1;
|
|
172
196
|
}
|
|
173
197
|
processCache(scope) {
|
|
174
198
|
if (!this.options.cache)
|
|
@@ -312,17 +336,24 @@ class DruxtClient {
|
|
|
312
336
|
}
|
|
313
337
|
if (!(this.index || {})[prefix]) {
|
|
314
338
|
const key = [this.indexKey, prefix || ""].join(":");
|
|
339
|
+
const before = this.processCache("index");
|
|
340
|
+
const since = before ? before.generation : void 0;
|
|
341
|
+
const started = this.indexCache.generation;
|
|
315
342
|
const request = this.indexRequests[key] || (this.indexRequests[key] = this.fetchIndex(prefix));
|
|
316
|
-
let
|
|
343
|
+
let fetched;
|
|
317
344
|
try {
|
|
318
|
-
|
|
345
|
+
fetched = await request;
|
|
319
346
|
} finally {
|
|
320
347
|
if (this.indexRequests[key] === request)
|
|
321
348
|
delete this.indexRequests[key];
|
|
322
349
|
}
|
|
350
|
+
if (started !== this.indexCache.generation) {
|
|
351
|
+
return resource ? fetched.index[resource] || false : fetched.index;
|
|
352
|
+
}
|
|
353
|
+
this.index[prefix] = fetched.index;
|
|
323
354
|
const shared = this.processCache("index");
|
|
324
|
-
if (shared
|
|
325
|
-
shared.set(key,
|
|
355
|
+
if (shared)
|
|
356
|
+
shared.set(key, fetched.index, fetched.lifetime, since);
|
|
326
357
|
}
|
|
327
358
|
return resource ? this.index[prefix][resource] || false : this.index[prefix];
|
|
328
359
|
}
|
|
@@ -365,8 +396,7 @@ class DruxtClient {
|
|
|
365
396
|
};
|
|
366
397
|
}
|
|
367
398
|
}
|
|
368
|
-
this.
|
|
369
|
-
return Math.min(...documents.map((document) => this.cacheLifetime(document)));
|
|
399
|
+
return { index, lifetime: Math.min(...documents.map((document) => this.cacheLifetime(document))) };
|
|
370
400
|
}
|
|
371
401
|
async getRelated(type, id, related, query, prefix = "") {
|
|
372
402
|
if (!id || !type || !related) {
|
|
@@ -412,8 +442,30 @@ class DruxtClient {
|
|
|
412
442
|
}
|
|
413
443
|
}
|
|
414
444
|
|
|
445
|
+
const safeEqual = (input, secret) => {
|
|
446
|
+
let diff = input.length ^ secret.length;
|
|
447
|
+
for (let i = 0; i < input.length; i++) {
|
|
448
|
+
diff |= input.charCodeAt(i) ^ secret.charCodeAt(i % secret.length);
|
|
449
|
+
}
|
|
450
|
+
return diff === 0;
|
|
451
|
+
};
|
|
452
|
+
const cacheClearHandler = (secret) => (req, res) => {
|
|
453
|
+
if (req.method !== "POST") {
|
|
454
|
+
res.statusCode = 405;
|
|
455
|
+
res.setHeader("Allow", "POST");
|
|
456
|
+
return res.end();
|
|
457
|
+
}
|
|
458
|
+
if (!safeEqual(String(req.headers["x-druxt-secret"] || ""), String(secret))) {
|
|
459
|
+
res.statusCode = 401;
|
|
460
|
+
return res.end();
|
|
461
|
+
}
|
|
462
|
+
resetProcessCache();
|
|
463
|
+
res.statusCode = 204;
|
|
464
|
+
res.end();
|
|
465
|
+
};
|
|
466
|
+
|
|
415
467
|
var name = "druxt";
|
|
416
|
-
var version = "0.25.0-dev.
|
|
468
|
+
var version = "0.25.0-dev.20260928061526";
|
|
417
469
|
var description = "The Fully Decoupled Drupal Framework for Nuxt.js.";
|
|
418
470
|
var keywords = [
|
|
419
471
|
"cms",
|
|
@@ -472,7 +524,7 @@ var dependencies = {
|
|
|
472
524
|
};
|
|
473
525
|
var devDependencies = {
|
|
474
526
|
"@storybook/addon-docs": "^6.5.16",
|
|
475
|
-
"druxt-test-utils": "1.0.0-dev.
|
|
527
|
+
"druxt-test-utils": "1.0.0-dev.20260928061526"
|
|
476
528
|
};
|
|
477
529
|
var peerDependencies = {
|
|
478
530
|
axios: "0.28.0",
|
|
@@ -518,6 +570,12 @@ const DruxtNuxtModule = async function(moduleOptions = {}) {
|
|
|
518
570
|
if (options.cache !== false) {
|
|
519
571
|
options.cache = this.options.dev ? false : { ...options.cache };
|
|
520
572
|
}
|
|
573
|
+
const { secret, ...cache } = options.cache || {};
|
|
574
|
+
if (options.cache)
|
|
575
|
+
options.cache = cache;
|
|
576
|
+
if (options.cache && secret) {
|
|
577
|
+
this.addServerMiddleware({ path: "/_druxt/cache/clear", handler: cacheClearHandler(secret) });
|
|
578
|
+
}
|
|
521
579
|
this.options.baseUrl = options.baseUrl = options.baseUrl.endsWith("/") ? options.baseUrl.slice(0, -1) : options.baseUrl;
|
|
522
580
|
this.options.endpoint = options.endpoint = options.endpoint.startsWith("/") ? options.endpoint : `/${options.endpoint}`;
|
|
523
581
|
const druxt = new DruxtClient(options.baseUrl, {
|
|
@@ -649,16 +707,45 @@ const dehydrateResources = ({ commit, queryObject, resources, prefix }) => {
|
|
|
649
707
|
return { id: data.id, type: data.type };
|
|
650
708
|
});
|
|
651
709
|
};
|
|
710
|
+
const collectionHash = (query) => {
|
|
711
|
+
if (!query)
|
|
712
|
+
return "_default";
|
|
713
|
+
const queryObject = getDrupalJsonApiParams(query).getQueryObject();
|
|
714
|
+
return md5__default["default"](JSON.stringify({ ...queryObject, fields: {}, include: [] }));
|
|
715
|
+
};
|
|
716
|
+
const flush = (tree, key, keys, leaf) => {
|
|
717
|
+
const byKey = tree[key];
|
|
718
|
+
if (!byKey)
|
|
719
|
+
return;
|
|
720
|
+
if (!keys.length && leaf === void 0)
|
|
721
|
+
return Vue__default["default"].delete(tree, key);
|
|
722
|
+
for (const k of keys.length ? keys : Object.keys(byKey)) {
|
|
723
|
+
if (!byKey[k])
|
|
724
|
+
continue;
|
|
725
|
+
if (leaf !== void 0)
|
|
726
|
+
Vue__default["default"].delete(byKey[k], leaf);
|
|
727
|
+
else
|
|
728
|
+
Vue__default["default"].delete(byKey, k);
|
|
729
|
+
}
|
|
730
|
+
};
|
|
652
731
|
const DruxtStore = ({ store }) => {
|
|
653
732
|
if (typeof store === "undefined") {
|
|
654
733
|
throw new TypeError("Vuex store not found.");
|
|
655
734
|
}
|
|
656
735
|
const namespace = "druxt";
|
|
657
736
|
const inFlight = new Map();
|
|
737
|
+
const generation = { value: 0 };
|
|
738
|
+
const flushInFlight = () => {
|
|
739
|
+
inFlight.clear();
|
|
740
|
+
generation.value += 1;
|
|
741
|
+
};
|
|
658
742
|
const share = (key, request) => {
|
|
659
743
|
if (!inFlight.has(key)) {
|
|
660
|
-
const clear = () => inFlight.delete(key);
|
|
661
744
|
const promise = request();
|
|
745
|
+
const clear = () => {
|
|
746
|
+
if (inFlight.get(key) === promise)
|
|
747
|
+
inFlight.delete(key);
|
|
748
|
+
};
|
|
662
749
|
inFlight.set(key, promise);
|
|
663
750
|
promise.then(clear, clear);
|
|
664
751
|
}
|
|
@@ -713,31 +800,33 @@ const DruxtStore = ({ store }) => {
|
|
|
713
800
|
resource = merge__default["default"](state.resources[type][id][prefix] || {}, resource, { arrayMerge: (dst, src) => src });
|
|
714
801
|
Vue__default["default"].set(state.resources[type][id], prefix, resource);
|
|
715
802
|
},
|
|
716
|
-
flushCollection(state, { type, hash, prefix }) {
|
|
803
|
+
flushCollection(state, { type, hash, query, prefix } = {}) {
|
|
804
|
+
flushInFlight();
|
|
717
805
|
if (!type)
|
|
718
|
-
Vue__default["default"].set(state, "collections", {});
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
else if (type && hash && !prefix)
|
|
722
|
-
Vue__default["default"].set(state.collections[type], hash, {});
|
|
723
|
-
else if (type && hash && prefix)
|
|
724
|
-
Vue__default["default"].set(state.collections[type][hash], prefix, {});
|
|
806
|
+
return Vue__default["default"].set(state, "collections", {});
|
|
807
|
+
const key = hash !== void 0 ? hash : query !== void 0 ? collectionHash(query) : void 0;
|
|
808
|
+
flush(state.collections, type, key !== void 0 ? [key] : [], prefix);
|
|
725
809
|
},
|
|
726
|
-
flushResource(state, { type, id, prefix }) {
|
|
810
|
+
flushResource(state, { type, id, prefix } = {}) {
|
|
811
|
+
flushInFlight();
|
|
727
812
|
if (!type)
|
|
728
|
-
Vue__default["default"].set(state, "resources", {});
|
|
729
|
-
|
|
730
|
-
Vue__default["default"].set(state.resources, type, {});
|
|
731
|
-
else if (type && id && !prefix)
|
|
732
|
-
Vue__default["default"].set(state.resources[type], id, {});
|
|
733
|
-
else if (type && id && prefix)
|
|
734
|
-
Vue__default["default"].set(state.resources[type][id], prefix, {});
|
|
813
|
+
return Vue__default["default"].set(state, "resources", {});
|
|
814
|
+
flush(state.resources, type, id !== void 0 ? [id] : [], prefix);
|
|
735
815
|
}
|
|
736
816
|
},
|
|
737
817
|
actions: {
|
|
818
|
+
clearCache({ commit }) {
|
|
819
|
+
if (this.$druxt && typeof this.$druxt.clearCache === "function")
|
|
820
|
+
this.$druxt.clearCache();
|
|
821
|
+
commit("flushCollection", {});
|
|
822
|
+
commit("flushResource", {});
|
|
823
|
+
for (const mutation of ["druxt/views/flushResults", "druxtMenu/flushEntities", "druxtRouter/flushRoutes"]) {
|
|
824
|
+
if (this._mutations[mutation])
|
|
825
|
+
commit(mutation, {}, { root: true });
|
|
826
|
+
}
|
|
827
|
+
},
|
|
738
828
|
async getCollection({ commit, state }, { type, query, prefix, bypassCache = false }) {
|
|
739
|
-
const
|
|
740
|
-
const hash = query ? md5__default["default"](JSON.stringify({ ...queryObject, fields: {}, include: [] })) : "_default";
|
|
829
|
+
const hash = collectionHash(query);
|
|
741
830
|
if (!bypassCache && ((state.collections[type] || {})[hash] || {})[prefix]) {
|
|
742
831
|
const cached = state.collections[type][hash][prefix];
|
|
743
832
|
const hydrate = (o) => (((state.resources[o.type] || {})[o.id] || {})[prefix] || {}).data;
|
|
@@ -751,10 +840,12 @@ const DruxtStore = ({ store }) => {
|
|
|
751
840
|
};
|
|
752
841
|
}
|
|
753
842
|
}
|
|
754
|
-
const key = JSON.stringify(["collection", prefix, type, hash,
|
|
843
|
+
const key = JSON.stringify(["collection", prefix, type, hash, getDrupalJsonApiParams(query).getQueryObject()]);
|
|
755
844
|
return share(key, async () => {
|
|
845
|
+
const since = generation.value;
|
|
756
846
|
const collection = await this.$druxt.getCollection(type, query, prefix);
|
|
757
|
-
|
|
847
|
+
if (since === generation.value)
|
|
848
|
+
commit("addCollection", { collection: { ...collection }, type, hash, prefix });
|
|
758
849
|
return collection;
|
|
759
850
|
});
|
|
760
851
|
},
|
|
@@ -768,7 +859,7 @@ const DruxtStore = ({ store }) => {
|
|
|
768
859
|
queryObject.fields[type] = Array.from(new Set([...fields2, ...includes])).filter((s) => s).join(",");
|
|
769
860
|
}
|
|
770
861
|
let included = [];
|
|
771
|
-
if (queryObject.include && storedResource) {
|
|
862
|
+
if (queryObject.include && storedResource && !bypassCache) {
|
|
772
863
|
const resources = await Promise.all(queryObject.include.split(",").filter((s) => Object.keys(storedResource.data.relationships || {}).includes(s)).map((key) => {
|
|
773
864
|
let { data } = storedResource.data.relationships[key];
|
|
774
865
|
data = Array.isArray(data) ? data : [data];
|
|
@@ -792,7 +883,7 @@ const DruxtStore = ({ store }) => {
|
|
|
792
883
|
}
|
|
793
884
|
const isFull = typeof (queryObject.fields || {})[type] !== "string";
|
|
794
885
|
let fields = isFull ? true : (queryObject.fields || {})[type];
|
|
795
|
-
if (storedResource && !isFull && fields) {
|
|
886
|
+
if (storedResource && !isFull && fields && !bypassCache) {
|
|
796
887
|
const queryFields = fields.split(",");
|
|
797
888
|
const resourceFields = [
|
|
798
889
|
...Object.keys(((storedResource || {}).data || {}).attributes || {}),
|
|
@@ -803,18 +894,22 @@ const DruxtStore = ({ store }) => {
|
|
|
803
894
|
queryObject.fields[type] = (missingFields || []).join(",") || void 0;
|
|
804
895
|
}
|
|
805
896
|
let resource;
|
|
897
|
+
const since = generation.value;
|
|
806
898
|
if (bypassCache || !storedResource || fields) {
|
|
807
899
|
try {
|
|
808
900
|
const key = JSON.stringify(["resource", prefix, type, id, queryObject]);
|
|
809
901
|
resource = await share(key, async () => {
|
|
902
|
+
const started = generation.value;
|
|
810
903
|
const response = await this.$druxt.getResource(type, id, getDrupalJsonApiParams(queryObject), prefix);
|
|
811
|
-
|
|
904
|
+
if (started === generation.value)
|
|
905
|
+
commit("addResource", { prefix, resource: { ...response } });
|
|
812
906
|
return response;
|
|
813
907
|
});
|
|
814
908
|
} catch (e) {
|
|
815
909
|
}
|
|
816
910
|
}
|
|
817
|
-
const
|
|
911
|
+
const stored = ((state.resources[type] || {})[id] || {})[prefix];
|
|
912
|
+
const result = { ...since === generation.value && stored ? stored : resource || stored };
|
|
818
913
|
if (queryObject.include && ((resource || {}).included || (storedResource || {}).included)) {
|
|
819
914
|
included = [
|
|
820
915
|
...(resource || {}).included || [],
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { resetProcessCache } from '../utils/processCache'
|
|
2
|
+
|
|
3
|
+
// Compares in time that depends on the caller's input alone: the loop runs its
|
|
4
|
+
// length, and the secret's content and length only feed the accumulator. Node's
|
|
5
|
+
// crypto is not used, as this file is part of the browser bundle too.
|
|
6
|
+
const safeEqual = (input, secret) => {
|
|
7
|
+
let diff = input.length ^ secret.length
|
|
8
|
+
for (let i = 0; i < input.length; i++) {
|
|
9
|
+
diff |= input.charCodeAt(i) ^ secret.charCodeAt(i % secret.length)
|
|
10
|
+
}
|
|
11
|
+
return diff === 0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Server middleware that empties the process cache when Drupal asks.
|
|
16
|
+
*
|
|
17
|
+
* Accepts `POST` with the secret in the `X-Druxt-Secret` header, never in the URL,
|
|
18
|
+
* so it stays out of access logs.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} secret - The shared secret, from `druxt.cache.secret`.
|
|
21
|
+
*
|
|
22
|
+
* @returns {Function} A Connect handler.
|
|
23
|
+
*/
|
|
24
|
+
export const cacheClearHandler = (secret) => (req, res) => {
|
|
25
|
+
if (req.method !== 'POST') {
|
|
26
|
+
res.statusCode = 405
|
|
27
|
+
res.setHeader('Allow', 'POST')
|
|
28
|
+
return res.end()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!safeEqual(String(req.headers['x-druxt-secret'] || ''), String(secret))) {
|
|
32
|
+
res.statusCode = 401
|
|
33
|
+
return res.end()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
resetProcessCache()
|
|
37
|
+
res.statusCode = 204
|
|
38
|
+
res.end()
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "druxt",
|
|
3
|
-
"version": "0.25.0-dev.
|
|
3
|
+
"version": "0.25.0-dev.20260928061526",
|
|
4
4
|
"description": "The Fully Decoupled Drupal Framework for Nuxt.js.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cms",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@storybook/addon-docs": "^6.5.16",
|
|
62
|
-
"druxt-test-utils": "1.0.0-dev.
|
|
62
|
+
"druxt-test-utils": "1.0.0-dev.20260928061526"
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
65
|
"axios": "0.28.0",
|