Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ The project uses several custom `$settings` in `settings.php`:
| `telegram_token` | Telegram Bot API token for comment moderation |
| `telegram_secret_token` | Secret token for Telegram webhook verification |
| `telegram_chat_id` | Telegram chat ID for moderation notifications |
| `app_foresight` | (default: `TRUE`) Allows to disable the ForesightJS prefetch library for an environment. Only active for anonymous users. When disabled, no link prefetching is performed. |

## 🧬 Quality Tools

Expand Down
10 changes: 10 additions & 0 deletions app/modules/platform/app_platform.libraries.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
foresightjs:
js:
/libraries/foresight/dist/foresight.umd.js: { weight: -20, minified: true, preprocess: false }

foresightjs.init:
js:
assets/js/foresight.init.js: { weight: -10 }
dependencies:
- core/drupal
- app_platform/foresightjs
80 changes: 80 additions & 0 deletions app/modules/platform/assets/js/foresight.init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
((Drupal, ForesightLib) => {

const { origin } = window.location;
const prefetched = new Set();

const IGNORE_URL_PATTERNS = [
'/user/logout',
'/admin',
'/edit',
'/ajax',
'/api',
];

const IGNORE_CONTAINER_SELECTORS = [
'#block-local-tasks-block a',
'.block-local-tasks-block a',
'#drupal-off-canvas a',
'#toolbar-administration a',
];

function prefetch(url) {
if (prefetched.has(url)) {
return;
}
prefetched.add(url);
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = url;
document.head.appendChild(link);
}

function shouldSkip(anchor) {
const { href } = anchor;
if (!href || !href.startsWith(origin)) {
return true;
}
if (anchor.hasAttribute('download') || anchor.hasAttribute('noprefetch')) {
return true;
}
if (anchor.classList.contains('use-ajax')) {
return true;
}
if (IGNORE_URL_PATTERNS.some((pattern) => href.includes(pattern))) {
return true;
}
if (IGNORE_CONTAINER_SELECTORS.some((selector) => anchor.matches(selector))) {
return true;
}
// Skip links to files (e.g. .pdf, .zip).
if (/\.[^/?#]{1,5}([?#]|$)/.test(new URL(href).pathname)) {
return true;
}
return false;
}

Drupal.behaviors.foresight = {
attach(context) {
if (!ForesightLib) {
return;
}

const { ForesightManager } = ForesightLib;

if (!ForesightManager.instance) {
ForesightManager.initialize();
}

context.querySelectorAll('a[href]').forEach((anchor) => {
if (shouldSkip(anchor)) {
return;
}
ForesightManager.instance.register({
element: anchor,
callback: () => prefetch(anchor.href),
});
});
},
};

})(Drupal, window.ForesightLib);
1 change: 1 addition & 0 deletions app/modules/platform/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
],
"require": {
"app/contract": "^1.0",
"app-asset/foresight": "^3.5",
"app-asset/photoswipe": "^5.4",
"league/html-to-markdown": "^5.1"
}
Expand Down
5 changes: 5 additions & 0 deletions app/modules/platform/src/AppPlatformServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Drupal\app_platform\Hook\Asset\CacheBustingQuerySetting;
use Drupal\app_platform\Hook\Core\LlmsPageAttachments;
use Drupal\app_platform\Hook\Theme\LibraryInfoAlter;
use Drupal\app_platform\Http\DateHeaderMiddleware;
use Drupal\app_platform\LanguageAwareStore\EventSubscriber\LanguageAwareSettingsRoutes;
use Drupal\app_platform\LanguageAwareStore\Factory\DatabaseLanguageAwareFactory;
use Drupal\app_platform\LanguageAwareStore\Factory\ServiceContainerLanguageAwareFactory;
Expand Down Expand Up @@ -38,6 +39,10 @@ public function register(ContainerBuilder $container): void {
->setPublic(TRUE)
->setAutoconfigured(TRUE);

$container->register('app_platform.date_header_middleware', DateHeaderMiddleware::class)
->setPublic(TRUE)
->addTag('http_middleware', ['priority' => 201]);

// Console.
$container
->autowire('app_platform.process.terminal', ProcessTerminal::class)
Expand Down
35 changes: 35 additions & 0 deletions app/modules/platform/src/Hook/ForesightPageAttachments.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Drupal\app_platform\Hook;

use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Session\SessionConfigurationInterface;
use Drupal\Core\Site\Settings;
use Symfony\Component\HttpFoundation\RequestStack;

#[Hook('page_attachments')]
final readonly class ForesightPageAttachments {

public function __construct(
private SessionConfigurationInterface $sessionConfiguration,
private RequestStack $requestStack,
) {}

public function __invoke(array &$attachments): void {
if (!Settings::get('app_foresight', TRUE)) {
return;
}

$attachments['#cache']['contexts'][] = 'session.exists';

$request = $this->requestStack->getCurrentRequest();
if ($request && $this->sessionConfiguration->hasSession($request)) {
return;
}

$attachments['#attached']['library'][] = 'app_platform/foresightjs.init';
}

}
74 changes: 74 additions & 0 deletions app/modules/platform/src/Http/DateHeaderMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace Drupal\app_platform\Http;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;

/**
* Fixes the frozen Date header produced by Drupal's page cache.
*
* Problem:
* Symfony sets the Date header when a Response object is first created.
* Drupal's page cache serializes the entire Response — including its
* headers — and replays it verbatim on cache hits. The Date header is
* frozen at the moment the cache entry was written, not at the moment
* the response is actually sent to the client.
*
* Browsers use Date to calculate the apparent age of a response:
*
* apparent_age = now - Date
* is_fresh = apparent_age < max-age
*
* With max-age=900 (15 min) and a cache entry written 20 minutes ago,
* the browser receives apparent_age=1200 > max-age=900 and treats the
* response as stale — even though it just arrived. A stale response
* cannot be stored in the prefetch cache, so <link rel="prefetch">
* requests made by ForesightJS are discarded and the next navigation
* still hits the server.
*
* Why CDNs do not have this problem:
* CDNs such as Cloudflare replace Date with the current time before
* forwarding the response. Without a CDN the frozen date reaches the
* browser unchanged.
*
* RFC note:
* RFC 7231 §7.1.1.2 defines Date as "the date and time at which the
* message was originated". This is intentionally ambiguous for cached
* responses: it could mean when the content was first generated (Drupal
* freezes it here) or when the HTTP message is transmitted to the client
* (what this middleware sets it to). RFC 7234 §5.1 offers the formally
* correct solution — adding an Age header so the browser knows how old
* the stored response is — but with max-age=900 and a cache entry that
* lives indefinitely (invalidated by cache tags, not by TTL), Age would
* still make the response appear stale. Resetting Date to the current
* transmission time is the pragmatic fix, and matches what CDNs such as
* Cloudflare do in practice.
*
* Solution:
* This middleware wraps the kernel stack at priority 201 (just above
* PageCache at 200) and overwrites Date with the current time on every
* response. The browser always sees apparent_age ≈ 0 and the freshness
* window starts from the moment of receipt.
*
* Last-Modified and ETag are not touched: Last-Modified still reflects
* the actual content-change time and ETag still enables conditional
* revalidation once the freshness window expires.
*
* @see https://httpwg.org/specs/rfc7231.html#header.date
*/
final readonly class DateHeaderMiddleware implements HttpKernelInterface {

public function __construct(private HttpKernelInterface $httpKernel) {}

#[\Override]
public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = TRUE): Response {
$response = $this->httpKernel->handle($request, $type, $catch);
$response->setDate(new \DateTime());
return $response;
}

}
1 change: 1 addition & 0 deletions assets/scaffold/local.settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,6 @@
$settings['telegram_token'] = NULL;
$settings['telegram_chat_id'] = NULL;
$settings['telegram_secret_token'] = NULL;
$settings['app_foresight'] = FALSE;

$config['cache_pilot.settings']['connection_dsn'] = 'tcp://php:9000';
25 changes: 25 additions & 0 deletions assets/vendor/foresight/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Rebuilds the ForesightJS IIFE bundle from npm.
# Run from repo root: docker compose exec -T php bash /var/www/html/assets/vendor/foresight/build.sh
set -euo pipefail

PACKAGE="js.foresight"
VERSION="3.5.0"
OUT_DIR="$(cd "$(dirname "$0")" && pwd)/dist"
TMP_DIR="$(mktemp -d)"

trap 'rm -rf "$TMP_DIR"' EXIT

echo "Installing $PACKAGE@$VERSION..."
npm install --prefix "$TMP_DIR" "$PACKAGE@$VERSION" --no-save --silent

echo "Bundling with esbuild..."
npx --prefix "$TMP_DIR" esbuild \
"$TMP_DIR/node_modules/$PACKAGE/dist/index.js" \
--bundle \
--format=iife \
--global-name=ForesightLib \
--minify \
--outfile="$OUT_DIR/foresight.umd.js"

echo "Done: $OUT_DIR/foresight.umd.js ($(wc -c < "$OUT_DIR/foresight.umd.js") bytes)"
5 changes: 5 additions & 0 deletions assets/vendor/foresight/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "app-asset/foresight",
"type": "drupal-library",
"version": "3.5.0"
}
9 changes: 9 additions & 0 deletions assets/vendor/foresight/dist/foresight.umd.js

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion config/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
],
"globals": {
"ColorScheme": true,
"AlpineOnce": true
"AlpineOnce": true,
"ForesightLib": true
},
"rules": {
"prettier/prettier": "off",
Expand Down
2 changes: 2 additions & 0 deletions config/cspell/dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ lightbox
LLMS
Llms
llms
foresightjs
Malyshev
Niklan
niklan
noprefetch
Nutgram
opsz
Photoswipe
Expand Down
1 change: 0 additions & 1 deletion config/sync/core.extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ module:
path_alias: 0
photoswipe: 0
phpass: 0
quicklink: 0
rabbit_hole: 0
redirect: 0
redirect_404: 0
Expand Down
20 changes: 0 additions & 20 deletions config/sync/quicklink.settings.yml

This file was deleted.

Loading