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 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 lifetime;
330
+ let fetched;
304
331
  try {
305
- lifetime = await request;
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 && this.index[prefix])
312
- shared.set(key, this.index[prefix], lifetime);
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.index[prefix] = index;
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.20260927110413";
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.20260927110413"
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
- else if (type && !hash && !prefix)
707
- Vue.set(state.collections, type, {});
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
- else if (type && !id && !prefix)
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 queryObject = getDrupalJsonApiParams(query).getQueryObject();
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, queryObject]);
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
- commit("addCollection", { collection: { ...collection }, type, hash, prefix });
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
- commit("addResource", { prefix, resource: { ...response } });
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 result = { ...((state.resources[type] || {})[id] || {})[prefix] };
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 lifetime;
343
+ let fetched;
317
344
  try {
318
- lifetime = await request;
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 && this.index[prefix])
325
- shared.set(key, this.index[prefix], lifetime);
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.index[prefix] = index;
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.20260927110413";
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.20260927110413"
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
- else if (type && !hash && !prefix)
720
- Vue__default["default"].set(state.collections, type, {});
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
- else if (type && !id && !prefix)
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 queryObject = getDrupalJsonApiParams(query).getQueryObject();
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, queryObject]);
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
- commit("addCollection", { collection: { ...collection }, type, hash, prefix });
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
- commit("addResource", { prefix, resource: { ...response } });
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 result = { ...((state.resources[type] || {})[id] || {})[prefix] };
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.20260927110413",
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.20260927110413"
62
+ "druxt-test-utils": "1.0.0-dev.20260928061526"
63
63
  },
64
64
  "peerDependencies": {
65
65
  "axios": "0.28.0",