Browse Source

speed up app

imwald
Silberengel 1 week ago
parent
commit
a9ad84c186
  1. 2
      Dockerfile
  2. 6
      assets/bootstrap.js
  3. 49
      assets/controllers/magazine_sync_controller.js
  4. 3
      compose.override.yaml
  5. 4
      config/packages/asset_mapper.yaml
  6. 8
      config/services.yaml
  7. 7
      config/unfold.yaml
  8. 2
      frankenphp/conf.d/10-app.ini
  9. 2
      frankenphp/conf.d/20-app.dev.ini
  10. 3
      importmap.php
  11. 1
      src/Controller/ArticleController.php
  12. 140
      src/Controller/DefaultController.php
  13. 106
      src/Controller/MagazineSyncController.php
  14. 163
      src/Service/MagazineContentService.php
  15. 93
      src/Service/MagazineIndexStore.php
  16. 165
      src/Service/MagazineRefresher.php
  17. 130
      src/Service/NostrClient.php
  18. 46
      src/Twig/Components/Header.php
  19. 32
      src/Twig/Components/Molecules/CategoryLink.php
  20. 25
      src/Twig/Components/Organisms/FeaturedList.php
  21. 7
      templates/base.html.twig
  22. 4
      templates/components/Header.html.twig
  23. 5
      templates/home.html.twig
  24. 9
      templates/pages/category.html.twig
  25. 3
      templates/ux/magazine/category_body.html.twig
  26. 10
      templates/ux/magazine/header_ul.html.twig
  27. 5
      templates/ux/magazine/home_body.html.twig

2
Dockerfile

@ -61,7 +61,7 @@ CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ] @@ -61,7 +61,7 @@ CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ]
# Dev FrankenPHP image
FROM frankenphp_base AS frankenphp_dev
ENV APP_ENV=dev XDEBUG_MODE=off
ENV APP_ENV=dev XDEBUG_MODE=develop
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"

6
assets/bootstrap.js vendored

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';
import ArticleCommentsController from './controllers/article_comments_controller.js';
import MagazineSyncController from './controllers/magazine_sync_controller.js';
const app = startStimulusApp();
@ -9,3 +10,8 @@ try { @@ -9,3 +10,8 @@ try {
} catch {
/* already registered by the bundle */
}
try {
app.register('magazine-sync', MagazineSyncController);
} catch {
/* already registered by the bundle */
}

49
assets/controllers/magazine_sync_controller.js

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
import { Controller } from "@hotwired/stimulus";
/**
* After first paint, refreshes Nostr magazine indices (server-side, 5s) and swaps header/body HTML.
*/
export default class extends Controller {
static targets = ["headerNav", "pageBody"];
static values = {
page: String,
slug: String,
url: String,
};
connect() {
this.sync();
}
async sync() {
const base = this.urlValue || "/ux/magazine-sync";
const params = new URLSearchParams();
params.set("page", this.pageValue || "article");
const slug = this.slugValue || "";
if (slug !== "") {
params.set("slug", slug);
}
const url = `${base}?${params.toString()}`;
try {
const res = await fetch(url, {
headers: { Accept: "application/json" },
credentials: "same-origin",
});
if (!res.ok) {
return;
}
const data = await res.json();
if (!data.ok) {
return;
}
if (this.hasHeaderNavTarget && data.header) {
this.headerNavTarget.outerHTML = data.header;
}
if (this.hasPageBodyTarget && data.body) {
this.pageBodyTarget.outerHTML = data.body;
}
} catch {
/* ignore network errors */
}
}
}

3
compose.override.yaml

@ -13,8 +13,9 @@ services: @@ -13,8 +13,9 @@ services:
# from the bind-mount for better performance by enabling the next line:
- /app/vendor
environment:
# develop: xdebug_info(), better stack traces, etc. Use debug,develop for step debugging (IDE).
# See https://xdebug.org/docs/all_settings#mode
XDEBUG_MODE: "${XDEBUG_MODE:-off}"
XDEBUG_MODE: "${XDEBUG_MODE:-develop}"
ports:
# Defaults avoid crowded 8080/8443; override with HTTP_PORT / HTTPS_PORT in .env
- "127.0.0.1:${HTTP_PORT:-9080}:80/tcp"

4
config/packages/asset_mapper.yaml

@ -1,5 +1,9 @@ @@ -1,5 +1,9 @@
framework:
asset_mapper:
# es-module-shims + native import maps can trigger "Multiple import maps are not allowed"
# in current browsers; rely on native import map support (Chrome 89+, Firefox 108+, Safari 16.4+).
# Re-enable the polyfill for older clients: set to `es-module-shims` and add the package in importmap.php.
importmap_polyfill: false
# The paths to make available to the asset mapper.
paths:
- assets/theme/local # Highest priority (overrides)

8
config/services.yaml

@ -34,7 +34,15 @@ services: @@ -34,7 +34,15 @@ services:
App\Service\NostrClient:
arguments:
$defaultRelayUrl: '%default_relay%'
$articleRelayUrls: '%article_relays%'
App\Twig\FooterLinksExtension:
arguments:
$footerLinksPath: '%footer_links%'
tags: [ 'twig.extension' ]
# Nostr index snapshots: distinct key prefix from other cache.app users.
App\Service\MagazineIndexStore:
arguments:
$pool: '@cache.app'
App\Service\MagazineRefresher:
arguments:
$appCache: '@cache.app'

7
config/unfold.yaml

@ -9,6 +9,13 @@ parameters: @@ -9,6 +9,13 @@ parameters:
og_subheading: 'Imwald Blog by Laeserin'
default_relay: 'wss://TheForest.nostr1.com'
# Extra wss:// URLs for article sync (articles:get), comment threads (NIP-22 / getArticleDiscussion),
# and any request that merges the default set with author-specific relays. default_relay is first; duplicates ignored.
article_relays: ['wss://christpill.nostr1.com', 'wss://nostr.land', 'wss://nostr.wine', 'wss://nostr21.com', 'wss://nostr.sovbit.host']
# Example:
# article_relays:
# - 'wss://nos.lol'
# - 'wss://relay.ditto.pub'
theme: 'imwald'
theme_color: '#8c2f1c'

2
frankenphp/conf.d/10-app.ini

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
expose_php = 0
; Default 128M is tight for long-form sync (large event JSON + Doctrine). Chunking helps; this adds headroom.
memory_limit = 256M
date.timezone = UTC
apc.enable_cli = 1
session.use_strict_mode = 1

2
frankenphp/conf.d/20-app.dev.ini

@ -2,4 +2,6 @@ @@ -2,4 +2,6 @@
; See https://github.com/docker/for-linux/issues/264
; The `client_host` below may optionally be replaced with `discover_client_host=yes`
; Add `start_with_request=yes` to start debug session on each request
; develop is required for xdebug_info() and related helpers. Docker sets XDEBUG_MODE (overrides this).
xdebug.mode = develop
xdebug.client_host = host.docker.internal

3
importmap.php

@ -57,7 +57,4 @@ return [ @@ -57,7 +57,4 @@ return [
'version' => '2.0.3',
'type' => 'css',
],
'es-module-shims' => [
'version' => '2.0.10',
],
];

1
src/Controller/ArticleController.php

@ -378,6 +378,7 @@ class ArticleController extends AbstractController @@ -378,6 +378,7 @@ class ArticleController extends AbstractController
return $this->render('pages/category.html.twig', [
'category' => $category,
'list' => $articles,
'sync_slug' => '',
]);
}

140
src/Controller/DefaultController.php

@ -4,148 +4,37 @@ declare(strict_types=1); @@ -4,148 +4,37 @@ declare(strict_types=1);
namespace App\Controller;
use App\Repository\ArticleRepository;
use App\Service\NostrClient;
use App\Service\MagazineContentService;
use Exception;
use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Psr\Log\LoggerInterface;
class DefaultController extends AbstractController
{
public function __construct(
private readonly CacheInterface $cache,
private readonly NostrClient $nostrClient,
private readonly ParameterBagInterface $params
) {}
private readonly MagazineContentService $magazineContent,
) {
}
/**
* @throws Exception
* @throws InvalidArgumentException
*/
#[Route('/', name: 'home')]
public function index(): Response
{
$npub = $this->params->get('npub');
$dTag = $this->params->get('d_tag');
// Key must match {@see Header}. Throw from the cache callback when the index is missing so `null`
// is not stored under this key (same pattern as {@see CategoryLink} / per-category cache).
$cacheKey = 'magazine_root_v2_'.$dTag;
try {
$mag = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub, $dTag) {
$item->expiresAfter(300);
$mag = $this->nostrClient->getMagazineIndex($npub, $dTag);
if ($mag === null) {
throw new \RuntimeException('Magazine root index not found for '.$dTag);
}
return $mag;
});
} catch (\Throwable) {
return $this->render('home.html.twig', [
'indices' => [],
]);
}
if ($mag === null) {
return $this->render('home.html.twig', [
'indices' => [],
'indices' => $this->magazineContent->getHomeCategoryIndexTags(),
]);
}
$tags = $mag->getTags();
$cats = array_filter($tags, function($tag) {
return ($tag[0] === 'a');
});
return $this->render('home.html.twig', [
'indices' => array_values($cats)
]);
}
/**
* @throws InvalidArgumentException
*/
#[Route('/cat/{slug}', name: 'magazine-category')]
public function magCategory($slug, ArticleRepository $articleRepository, LoggerInterface $logger): Response
public function magCategory(string $slug): Response
{
$npub = $this->params->get('npub');
$cacheKey = 'magazine-' . $slug;
try {
$catIndex = $this->cache->get($cacheKey, function ($item) use ($npub, $slug) {
$item->expiresAfter(300); // 5 minutes
$mag = $this->nostrClient->getMagazineIndex($npub, $slug);
if ($mag === null) {
throw new \RuntimeException('Category index not found for '.$slug);
}
return $mag;
});
} catch (\Throwable) {
$catIndex = null;
}
$list = [];
$coordinates = [];
$category = [];
if ($catIndex) {
foreach ($catIndex->getTags() as $tag) {
if ($tag[0] === 'title') {
$category['title'] = $tag[1];
}
if ($tag[0] === 'summary') {
$category['summary'] = $tag[1];
}
if ($tag[0] === 'a') {
$coordinates[] = $tag[1];
}
}
}
if (!empty($coordinates)) {
$slugs = array_map(static function ($coordinate) {
$parts = explode(':', (string) $coordinate, 3);
return trim((string) end($parts));
}, $coordinates);
$slugs = array_values(array_filter($slugs, static fn (string $s): bool => $s !== ''));
$articles = $articleRepository->findBySlugsCriteria($slugs);
$slugMap = [];
foreach ($articles as $item) {
$slug = trim((string) $item->getSlug());
if ($slug !== '') {
if (!isset($slugMap[$slug])) {
$slugMap[$slug] = $item;
} else {
$existingItem = $slugMap[$slug];
if ($item->getCreatedAt() > $existingItem->getCreatedAt()) {
$slugMap[$slug] = $item;
}
}
}
}
foreach ($coordinates as $coordinate) {
$parts = explode(':', (string) $coordinate, 3);
$slugKey = trim((string) end($parts));
if ($slugKey !== '' && isset($slugMap[$slugKey])) {
$list[] = $slugMap[$slugKey];
}
}
}
$category['title'] = $category['title'] ?? '';
$category['summary'] = $category['summary'] ?? '';
$data = $this->magazineContent->getCategoryPageData($slug);
return $this->render('pages/category.html.twig', [
'list' => $list,
'category' => $category
'list' => $data['list'],
'category' => $data['category'],
'sync_slug' => $slug,
]);
}
@ -165,17 +54,18 @@ class DefaultController extends AbstractController @@ -165,17 +54,18 @@ class DefaultController extends AbstractController
$embed = new \Embed\Embed();
$info = $embed->get($url);
if (!$info) {
throw new \Exception('No OG data found');
throw new Exception('No OG data found');
}
return $this->render('components/Molecules/OgPreview.html.twig', [
'og' => [
'title' => $info->title,
'description' => $info->description,
'image' => $info->image,
'url' => $url
]
'url' => $url,
],
]);
} catch (\Exception $e) {
} catch (Exception $e) {
return new Response('<div class="alert alert-warning">Unable to load OG preview for '.htmlspecialchars($url).'</div>', 200);
}
}

106
src/Controller/MagazineSyncController.php

@ -0,0 +1,106 @@ @@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Service\MagazineContentService;
use App\Service\MagazineRefresher;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
/** Stale-first: the main request only reads {@see \App\Service\MagazineIndexStore}; this refetches Nostr, updates that store, and returns HTML fragments for Stimulus to patch the document. */
#[AsController]
final class MagazineSyncController
{
public function __construct(
private readonly Environment $twig,
private readonly MagazineRefresher $refresher,
private readonly MagazineContentService $magazineContent,
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
) {
}
#[Route('/ux/magazine-sync', name: 'ux_magazine_sync', methods: ['GET'])]
public function __invoke(Request $request): JsonResponse
{
@set_time_limit(8);
@ini_set('max_execution_time', '8');
try {
$page = (string) $request->query->get('page', 'article');
if (!\in_array($page, ['home', 'category', 'article', 'articles'], true)) {
$page = 'article';
}
$slug = (string) $request->query->get('slug', '');
$prefer = $slug !== '' ? [$slug] : [];
try {
$this->refresher->refreshFromRelays(8, $prefer);
} catch (\Throwable $e) {
$this->logger->warning('MagazineSyncController: refresh failed', [
'message' => $e->getMessage(),
'exception' => $e,
]);
return new JsonResponse(
['ok' => false, 'error' => 'refresh_failed', 'message' => $e->getMessage()],
Response::HTTP_OK
);
}
$community = (bool) $this->params->get('community_articles');
$tags = $this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly();
$globals = [
'magazine_community_articles' => $community,
];
$header = $this->twig->render('ux/magazine/header_ul.html.twig', array_merge($globals, [
'cats' => $tags,
]));
$body = null;
if ($page === 'home') {
$body = $this->twig->render('ux/magazine/home_body.html.twig', array_merge($globals, [
'indices' => $tags,
]));
} elseif ($page === 'category' && $slug !== '') {
$data = $this->magazineContent->getCategoryPageData($slug);
$body = $this->twig->render('ux/magazine/category_body.html.twig', array_merge($globals, [
'list' => $data['list'],
'category' => $data['category'],
]));
} elseif ($page === 'articles') {
$body = null;
}
return new JsonResponse([
'ok' => true,
'header' => $header,
'body' => $body,
]);
} catch (\Throwable $e) {
$this->logger->error('MagazineSyncController: unexpected failure', [
'message' => $e->getMessage(),
'exception' => $e,
]);
return new JsonResponse(
[
'ok' => false,
'error' => 'server_error',
'message' => 'Magazine UI sync could not be rendered.',
],
Response::HTTP_OK
);
}
}
}

163
src/Service/MagazineContentService.php

@ -0,0 +1,163 @@ @@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Article;
use App\Entity\Event;
use App\Repository\ArticleRepository;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
* Magazine index events for templates. Reads {@see MagazineIndexStore} first; on a cold cache or when
* the last successful relay sync is older than {@see self::ROOT_REVALIDATE_SECONDS}, the service
* calls {@see MagazineRefresher} so the root index (and nav) can pick up new categories.
*/
final class MagazineContentService
{
/** Re-fetch root from relays at most this often so new `a` tags appear in the header. */
private const ROOT_REVALIDATE_SECONDS = 300;
public function __construct(
private readonly MagazineIndexStore $store,
private readonly MagazineRefresher $refresher,
private readonly ParameterBagInterface $params,
private readonly ArticleRepository $articleRepository,
) {
}
/**
* "indices" for the home template: Nostr `a` tag rows for each category.
*
* @return list<array<int, string>>
*/
public function getHomeCategoryIndexTags(): array
{
$npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag');
if ($this->store->getRoot($npub, $dTag) === null) {
$this->refresher->refreshFromRelays(8, []);
} elseif ($this->shouldRevalidateRootFromRelay()) {
$this->refresher->refreshFromRelays(8, []);
}
return $this->getHomeCategoryAIndexTagsFromStoreOnly();
}
/**
* Category `a` tags from the persisted root only (no relay). Used after /ux/magazine-sync
* has already called {@see MagazineRefresher::refreshFromRelays}.
*
* @return list<array<int, string>>
*/
public function getHomeCategoryAIndexTagsFromStoreOnly(): array
{
return $this->categoryATagsFromStoredRoot();
}
/**
* @return list<array<int, string>>
*/
private function categoryATagsFromStoredRoot(): array
{
$npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag');
$mag = $this->store->getRoot($npub, $dTag);
return $this->categoryATagsFromMag($mag);
}
/**
* @return list<array<int, string>>
*/
private function categoryATagsFromMag(?Event $mag): array
{
if ($mag === null) {
return [];
}
$tags = $mag->getTags();
$cats = array_filter($tags, static function (mixed $tag): bool {
return \is_array($tag) && ($tag[0] ?? null) === 'a';
});
return array_values($cats);
}
private function shouldRevalidateRootFromRelay(): bool
{
$age = $this->refresher->getSecondsSinceLastRelayRun();
if ($age === null) {
return true;
}
return $age > self::ROOT_REVALIDATE_SECONDS;
}
/**
* @return array{list: list<Article>, category: array{title: string, summary: string}}
*/
public function getCategoryPageData(string $slug): array
{
$catIndex = $this->store->getCategory($slug);
if ($catIndex === null) {
$this->refresher->refreshFromRelays(8, [$slug]);
$catIndex = $this->store->getCategory($slug);
}
$list = [];
$coordinates = [];
$category = [];
if ($catIndex) {
foreach ($catIndex->getTags() as $tag) {
if ($tag[0] === 'title') {
$category['title'] = (string) $tag[1];
}
if ($tag[0] === 'summary') {
$category['summary'] = (string) $tag[1];
}
if ($tag[0] === 'a') {
$coordinates[] = $tag[1];
}
}
}
if (!empty($coordinates)) {
$slugs = array_map(static function ($coordinate) {
$parts = explode(':', (string) $coordinate, 3);
return trim((string) end($parts));
}, $coordinates);
$slugs = array_values(array_filter($slugs, static fn (string $s): bool => $s !== ''));
$articles = $this->articleRepository->findBySlugsCriteria($slugs);
$slugMap = [];
foreach ($articles as $item) {
$s = trim((string) $item->getSlug());
if ($s !== '') {
if (!isset($slugMap[$s])) {
$slugMap[$s] = $item;
} else {
$existingItem = $slugMap[$s];
if ($item->getCreatedAt() > $existingItem->getCreatedAt()) {
$slugMap[$s] = $item;
}
}
}
}
foreach ($coordinates as $coordinate) {
$parts = explode(':', (string) $coordinate, 3);
$slugKey = trim((string) end($parts));
if ($slugKey !== '' && isset($slugMap[$slugKey])) {
$list[] = $slugMap[$slugKey];
}
}
}
$category['title'] = $category['title'] ?? '';
$category['summary'] = $category['summary'] ?? '';
return [
'list' => $list,
'category' => $category,
];
}
}

93
src/Service/MagazineIndexStore.php

@ -0,0 +1,93 @@ @@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Event;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
/**
* Read/write persisted magazine Nostr index events (kinds 30040) without callback-based relay I/O
* on the request path. Updated by {@see MagazineRefresher} or the /ux/magazine-sync action.
*/
final class MagazineIndexStore
{
private const ROOT_PREFIX = 'mroot_v1_';
private const CAT_PREFIX = 'mcat_v1_';
/** 30 days — we refresh on page load, TTL is a safety cap if sync stops working. */
private const PERSIST_TTL = 2_592_000;
public function __construct(
private readonly CacheItemPoolInterface $pool,
) {
}
public function getRoot(string $npub, string $dTag): ?Event
{
$item = $this->pool->getItem($this->rootKey($npub, $dTag));
if (!$item->isHit()) {
return null;
}
return $this->unwrap($item->get());
}
public function getCategory(string $slug): ?Event
{
if ($slug === '') {
return null;
}
$item = $this->pool->getItem(self::CAT_PREFIX.$slug);
if (!$item->isHit()) {
return null;
}
return $this->unwrap($item->get());
}
/**
* @throws InvalidArgumentException
*/
public function putRoot(string $npub, string $dTag, Event $event): void
{
$item = $this->pool->getItem($this->rootKey($npub, $dTag));
$item->set(serialize($event));
$item->expiresAfter(self::PERSIST_TTL);
$this->pool->save($item);
}
/**
* @throws InvalidArgumentException
*/
public function putCategory(string $slug, Event $event): void
{
if ($slug === '') {
return;
}
$item = $this->pool->getItem(self::CAT_PREFIX.$slug);
$item->set(serialize($event));
$item->expiresAfter(self::PERSIST_TTL);
$this->pool->save($item);
}
private function rootKey(string $npub, string $dTag): string
{
return self::ROOT_PREFIX.hash('sha256', $npub."\0".$dTag);
}
private function unwrap(mixed $value): ?Event
{
if (!\is_string($value) || $value === '') {
return null;
}
$e = unserialize($value, ['allowed_classes' => [Event::class]]);
if (!$e instanceof Event) {
return null;
}
return $e;
}
}

165
src/Service/MagazineRefresher.php

@ -0,0 +1,165 @@ @@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Event;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
* Pulls magazine indices from relays within a wall-clock budget and persists them to {@see MagazineIndexStore}.
*/
final class MagazineRefresher
{
private const RELAY_STAMP_KEY = 'mag_relay_v1';
public function __construct(
private readonly NostrClient $nostrClient,
private readonly MagazineIndexStore $store,
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
private readonly CacheItemPoolInterface $appCache,
) {
}
/**
* Fetches the root index then each category index until $budgetSeconds elapses. $preferSlugs
* are requested first (e.g. current /cat route) so they are less likely to miss the budget.
*/
public function refreshFromRelays(int $budgetSeconds = 8, array $preferSlugs = []): void
{
$budgetSeconds = max(1, min(30, $budgetSeconds));
$deadline = microtime(true) + $budgetSeconds;
$npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag');
// Do not set max_execution_time to the *remaining* soft budget: PHP resets the timer, so
// after a 6s root fetch, "2s left" would become a 2s hard cap for the *next* relay I/O
// (e.g. slow TLS) and can fatal. Cap once with headroom; the $deadline loop limits work.
$this->applyExecutionTimeCap($budgetSeconds);
$root = $this->nostrClient->getMagazineIndex($npub, $dTag);
if ($root === null) {
$this->logger->warning('MagazineRefresher: root index not returned from relay', [
'd_tag' => $dTag,
]);
return;
}
$this->store->putRoot($npub, $dTag, $root);
$slugs = $this->orderedCategorySlugs($this->categorySlugsFromRoot($root), $preferSlugs);
foreach ($slugs as $slug) {
if (microtime(true) >= $deadline) {
$this->logger->notice('MagazineRefresher: stopped at time budget; some categories not fetched', [
'unprocessed_from' => $slug,
]);
break;
}
try {
$cat = $this->nostrClient->getMagazineIndex($npub, $slug);
if ($cat !== null) {
$this->store->putCategory($slug, $cat);
}
} catch (\Throwable $e) {
$this->logger->error('MagazineRefresher: category fetch failed', [
'slug' => $slug,
'message' => $e->getMessage(),
]);
}
}
$this->touchLastRelayTime();
}
/**
* @throws InvalidArgumentException
*/
public function getSecondsSinceLastRelayRun(): ?int
{
try {
$item = $this->appCache->getItem(self::RELAY_STAMP_KEY);
} catch (InvalidArgumentException) {
return null;
}
if (!$item->isHit()) {
return null;
}
return time() - (int) $item->get();
}
/**
* Child category indices are kind 30040; each root "a" tag is a NIP-33 address
* kind:hexpubkey:d-identifier. The third segment is the child #d (e.g. the long
* newsroom-…-category-… string), not a shortened title.
*
* @return list<string>
*/
private function categorySlugsFromRoot(Event $root): array
{
$slugs = [];
foreach ($root->getTags() as $tag) {
if (($tag[0] ?? null) !== 'a' || !isset($tag[1])) {
continue;
}
$parts = explode(':', (string) $tag[1], 3);
if (\count($parts) < 3) {
continue;
}
$s = trim((string) end($parts));
if ($s !== '' && !\in_array($s, $slugs, true)) {
$slugs[] = $s;
}
}
return $slugs;
}
/**
* @param list<string> $allFromRoot
* @param list<string> $prefer
* @return list<string>
*/
private function orderedCategorySlugs(array $allFromRoot, array $prefer): array
{
$prefer = array_values(array_filter($prefer, static function (string $s): bool {
return $s !== '';
}));
$out = $prefer;
foreach ($allFromRoot as $s) {
if (!\in_array($s, $out, true)) {
$out[] = $s;
}
}
return $out;
}
/**
* @throws InvalidArgumentException
*/
private function touchLastRelayTime(): void
{
$item = $this->appCache->getItem(self::RELAY_STAMP_KEY);
$item->set((string) time());
$item->expiresAfter(86_400);
$this->appCache->save($item);
}
/**
* One generous ceiling for PHP so relay/WebSocket I/O in one Nostr call can outlast the soft
* $deadline by seconds without a fatal, while the loop still stops *starting* new fetches in time.
*/
private function applyExecutionTimeCap(int $budgetSeconds): void
{
$sec = max(30, min(120, $budgetSeconds + 30));
@set_time_limit($sec);
@ini_set('max_execution_time', (string) $sec);
}
}

130
src/Service/NostrClient.php

@ -26,6 +26,9 @@ class NostrClient @@ -26,6 +26,9 @@ class NostrClient
{
private RelaySet $defaultRelaySet;
/**
* @param list<string> $articleRelayUrls extra relays for the default set (default_relay is always first)
*/
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ManagerRegistry $managerRegistry,
@ -33,26 +36,56 @@ class NostrClient @@ -33,26 +36,56 @@ class NostrClient
private readonly TokenStorageInterface $tokenStorage,
private readonly LoggerInterface $logger,
private readonly string $defaultRelayUrl,
private readonly array $articleRelayUrls,
private readonly CacheInterface $relayQueryCache,
) {
$this->defaultRelaySet = new RelaySet();
$this->defaultRelaySet->addRelay(new Relay($this->defaultRelayUrl));
$this->defaultRelaySet = $this->buildArticleRelaySet();
}
/**
* Build a fresh relay set: default relay plus optional extras (deduped).
* Never reuse {@see $defaultRelaySet} as a mutable base — that used to append relays
* onto the singleton forever and multiplied every nostr request latency.
* default_relay + article_relays from config, in order, deduplicated. Used for the static
* default set and as the base when merging author/extra relay URLs in {@see createRelaySet()}.
*
* @return list<string>
*/
private function createRelaySet(array $relayUrls): RelaySet
private function configuredArticleRelayUrlList(): array
{
$relaySet = new RelaySet();
$seen = [];
foreach (array_merge([$this->defaultRelayUrl], $relayUrls) as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '') {
$out = [];
foreach (array_merge([$this->defaultRelayUrl], $this->articleRelayUrls) as $url) {
if (!\is_string($url) || $url === '' || isset($seen[$url])) {
continue;
}
if (isset($seen[$relayUrl])) {
$seen[$url] = true;
$out[] = $url;
}
if ($out === []) {
$out[] = $this->defaultRelayUrl;
}
return $out;
}
private function buildArticleRelaySet(): RelaySet
{
$relaySet = new RelaySet();
foreach ($this->configuredArticleRelayUrlList() as $url) {
$relaySet->addRelay(new Relay($url));
}
return $relaySet;
}
/**
* Merges all configured article relays (default + article_relays) with the given URLs in order, deduped.
* Used for comment threads (getArticleDiscussion), per-author fetches, etc.
*/
private function createRelaySet(array $relayUrls): RelaySet
{
$relaySet = new RelaySet();
$seen = [];
foreach (array_merge($this->configuredArticleRelayUrlList(), $relayUrls) as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) {
continue;
}
$seen[$relayUrl] = true;
@ -153,16 +186,14 @@ class NostrClient @@ -153,16 +186,14 @@ class NostrClient
$request = new Request($relays, $requestMessage);
$response = $request->send();
// response is an n-dimensional array, where n is the number of relays in the set
// check that response has events in the results
foreach ($response as $relayRes) {
$filtered = array_filter($relayRes, function ($item) {
return $item->type === 'EVENT';
$wrappers = $this->processResponse($request->send(), function (object $event) {
$w = new \stdClass();
$w->event = $event;
return $w;
});
if (count($filtered) > 0) {
$this->saveLongFormContent($filtered);
}
if ($wrappers !== []) {
$this->saveLongFormContent($wrappers);
}
// TODO handle relays that require auth
}
@ -180,37 +211,52 @@ class NostrClient @@ -180,37 +211,52 @@ class NostrClient
return $relaySet->send();
}
/**
* Backfill long-form (NIP-23) in time windows so relay responses and PHP stay bounded (avoids
* OOM on year-wide queries with many relays). ~60 days per step (≈2 months).
*/
private const LONGFORM_BACKFILL_CHUNK_SECONDS = 5184000; // 60 days
/**
* Long-form Content
* NIP-23
*/
public function getLongFormContent($from = null, $to = null): void
{
$toTs = $to !== null ? (int) $to : time();
$fromTs = $from !== null ? (int) $from : strtotime('-1 week');
if ($fromTs >= $toTs) {
return;
}
$chunk = self::LONGFORM_BACKFILL_CHUNK_SECONDS;
for ($windowFrom = $fromTs; $windowFrom < $toTs; $windowFrom += $chunk) {
$windowTo = min($windowFrom + $chunk, $toTs);
$this->getLongFormContentForTimeWindow($windowFrom, $windowTo);
$this->entityManager->clear();
}
}
private function getLongFormContentForTimeWindow(int $since, int $until): void
{
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([KindsEnum::LONGFORM]);
$filter->setSince(strtotime('-1 week')); // default
if ($from !== null) {
$filter->setSince($from);
}
if ($to !== null) {
$filter->setUntil($to);
}
$filter->setSince($since);
$filter->setUntil($until);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
$request = new Request($this->defaultRelaySet, $requestMessage);
$response = $request->send();
// response is an n-dimensional array, where n is the number of relays in the set
// check that response has events in the results
foreach ($response as $relayRes) {
$filtered = array_filter($relayRes, function ($item) {
return $item->type === 'EVENT';
$wrappers = $this->processResponse($request->send(), function (object $event) {
$w = new \stdClass();
$w->event = $event;
return $w;
});
if (count($filtered) > 0) {
$this->saveLongFormContent($filtered);
}
if ($wrappers !== []) {
$this->saveLongFormContent($wrappers);
}
}
@ -541,11 +587,8 @@ class NostrClient @@ -541,11 +587,8 @@ class NostrClient
{
$seen = [];
$out = [];
foreach (array_merge([$this->defaultRelayUrl], $relayUrls) as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '') {
continue;
}
if (isset($seen[$relayUrl])) {
foreach (array_merge($this->configuredArticleRelayUrlList(), $relayUrls) as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) {
continue;
}
$seen[$relayUrl] = true;
@ -963,9 +1006,8 @@ class NostrClient @@ -963,9 +1006,8 @@ class NostrClient
// Continue with default relays
}
// If no author relays found, add default relay
if (empty($relayList)) {
$relayList = [$this->defaultRelayUrl];
$relayList = [];
}
// Ensure we use a RelaySet
@ -1237,6 +1279,10 @@ class NostrClient @@ -1237,6 +1279,10 @@ class NostrClient
/**
* Latest kind 30040 index for this author and #d tag, as {@see PublicationEventEntity}
* so callers can use {@see PublicationEventEntity::getTags()} (relay payloads are otherwise stdClass).
*
* The magazine root uses the site d_tag from config. Each category uses the full child d
* (third segment of the root "a" address). A category 30040 lists 30023 article "a" tags, not
* further nested 30040 indices.
*/
public function getMagazineIndex(mixed $npub, mixed $dTag): ?PublicationEventEntity
{

46
src/Twig/Components/Header.php

@ -4,11 +4,7 @@ declare(strict_types=1); @@ -4,11 +4,7 @@ declare(strict_types=1);
namespace App\Twig\Components;
use App\Service\NostrClient;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use App\Service\MagazineContentService;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
@ -16,45 +12,9 @@ class Header @@ -16,45 +12,9 @@ class Header
{
public array $cats;
/**
* @throws InvalidArgumentException
*/
public function __construct(
private readonly CacheInterface $cache,
private readonly ParameterBagInterface $params,
private readonly NostrClient $nostrClient,
private readonly MagazineContentService $magazineContent,
) {
$dTag = (string) $this->params->get('d_tag');
$npub = (string) $this->params->get('npub');
// Same key as {@see DefaultController::index()}. If the relay returns nothing, throw from the
// callback so Symfony does not persist `null` — otherwise categories vanish until TTL (~5 min).
$cacheKey = 'magazine_root_v2_'.$dTag;
try {
$mag = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub, $dTag) {
$item->expiresAfter(300);
$mag = $this->nostrClient->getMagazineIndex($npub, $dTag);
if ($mag === null) {
throw new \RuntimeException('Magazine root index not found for '.$dTag);
}
return $mag;
});
} catch (\Throwable) {
$this->cats = [];
return;
}
if ($mag === null) {
$this->cats = [];
return;
}
$tags = $mag->getTags();
$this->cats = array_filter($tags, static function ($tag): bool {
return ($tag[0] ?? null) === 'a';
});
$this->cats = $this->magazineContent->getHomeCategoryIndexTags();
}
}

32
src/Twig/Components/Molecules/CategoryLink.php

@ -2,10 +2,7 @@ @@ -2,10 +2,7 @@
namespace App\Twig\Components\Molecules;
use App\Service\NostrClient;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use App\Service\MagazineIndexStore;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
@ -16,9 +13,7 @@ final class CategoryLink @@ -16,9 +13,7 @@ final class CategoryLink
public string $slug = '';
public function __construct(
private readonly CacheInterface $cache,
private readonly ParameterBagInterface $params,
private readonly NostrClient $nostrClient,
private readonly MagazineIndexStore $store,
) {
}
@ -34,31 +29,14 @@ final class CategoryLink @@ -34,31 +29,14 @@ final class CategoryLink
}
$this->title = $this->slug;
$npub = (string) $this->params->get('npub');
// Same cache key/TTL as DefaultController::magCategory(); load from relay on miss (not read-only).
// The cache callback must return data on miss; otherwise the homepage shows raw d-tags.
try {
$cat = $this->cache->get('magazine-' . $this->slug, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(300);
$mag = $this->nostrClient->getMagazineIndex($npub, $this->slug);
if ($mag === null) {
// Do not persist null: FeaturedList would get a cache hit and call getTags() on null.
throw new \RuntimeException('Category index not found for '.$this->slug);
}
return $mag;
});
} catch (\Throwable) {
return;
}
$cat = $this->store->getCategory($this->slug);
if (!\is_object($cat) || !\method_exists($cat, 'getTags')) {
return;
}
$tags = $cat->getTags();
$titleTags = array_filter($tags, static function ($tag): bool {
return isset($tag[0]) && $tag[0] === 'title' && isset($tag[1]);
$titleTags = array_filter($tags, static function (mixed $tag): bool {
return \is_array($tag) && ($tag[0] ?? null) === 'title' && isset($tag[1]);
});
$first = array_key_first($titleTags);
if ($first !== null) {

25
src/Twig/Components/Organisms/FeaturedList.php

@ -3,11 +3,8 @@ @@ -3,11 +3,8 @@
namespace App\Twig\Components\Organisms;
use App\Repository\ArticleRepository;
use App\Service\NostrClient;
use App\Service\MagazineIndexStore;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
@ -20,10 +17,8 @@ final class FeaturedList @@ -20,10 +17,8 @@ final class FeaturedList
public array $list = [];
public function __construct(
private readonly CacheInterface $cache,
private readonly MagazineIndexStore $store,
private readonly ArticleRepository $articleRepository,
private readonly NostrClient $nostrClient,
private readonly ParameterBagInterface $params,
) {
}
@ -43,22 +38,8 @@ final class FeaturedList @@ -43,22 +38,8 @@ final class FeaturedList
}
$slug = $parts[2];
$npub = (string) $this->params->get('npub');
try {
$catIndex = $this->cache->get('magazine-' . $slug, function (ItemInterface $item) use ($npub, $slug) {
$item->expiresAfter(300);
$mag = $this->nostrClient->getMagazineIndex($npub, $slug);
if ($mag === null) {
throw new \RuntimeException('Category index not found for '.$slug);
}
return $mag;
});
} catch (\Throwable) {
return;
}
$catIndex = $this->store->getCategory($slug);
if (!\is_object($catIndex) || !\method_exists($catIndex, 'getTags')) {
return;
}

7
templates/base.html.twig

@ -29,7 +29,12 @@ @@ -29,7 +29,12 @@
<link rel="stylesheet" href="{{ asset('theme.css') }}">
{% endblock %}
</head>
<body data-controller="service-worker">
<body
data-controller="service-worker magazine-sync"
data-magazine-sync-page-value="{% block magazine_sync_page %}article{% endblock %}"
data-magazine-sync-slug-value="{% block magazine_sync_slug %}{% endblock %}"
data-magazine-sync-url-value="{{ path('ux_magazine_sync') }}"
>
<twig:Header />

4
templates/components/Header.html.twig

@ -11,9 +11,9 @@ @@ -11,9 +11,9 @@
<button class="hamburger btn btn-secondary" data-action="click->menu#toggle" aria-label="Menu">&#9776;</button>
</div>
<div class="header__categories" data-menu-target="menu">
<ul>
<ul data-magazine-sync-target="headerNav">
{% for category in cats %}
<li><twig:Molecules:CategoryLink category="{{ category }}" /></li>
<li><twig:Molecules:CategoryLink :category="category" /></li>
{% endfor %}
{% if magazine_community_articles %}
<li>

5
templates/home.html.twig

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
{% extends 'base.html.twig' %}
{% block magazine_sync_page %}home{% endblock %}
{% block ogtags %}
{% set _og_image = absolute_url(asset('og-image.jpg')) %}
<meta property="og:type" content="website">
@ -18,10 +20,11 @@ @@ -18,10 +20,11 @@
{% endblock %}
{% block body %}
{# content #}
<div class="home-body" data-magazine-sync-target="pageBody">
{% for item in indices %}
<twig:Organisms:FeaturedList :category="item" class="featured-list"/>
{% endfor %}
</div>
{% endblock %}
{% block aside %}

9
templates/pages/category.html.twig

@ -1,5 +1,8 @@ @@ -1,5 +1,8 @@
{% extends 'base.html.twig' %}
{% block magazine_sync_page %}{% if app.request.attributes.get('_route') == 'articles' %}articles{% else %}category{% endif %}{% endblock %}
{% block magazine_sync_slug %}{{ (sync_slug|default(''))|e('html_attr') }}{% endblock %}
{% block title %}{{ (category.title|default(''))|trim != '' ? category.title|trim ~ ' — ' ~ website_name : website_name }}{% endblock %}
{% block meta_description %}
@ -13,11 +16,11 @@ @@ -13,11 +16,11 @@
{% set _og_image = absolute_url(asset('og-image.jpg')) %}
<meta property="og:title" content="{{ _title|e('html_attr') }}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ app.request.attributes.get('_route') == 'articles' ? url('articles') : url('magazine-category', {slug: app.request.attributes.get('slug')}) }}">
<meta property="og:url" content="{{ app.request.attributes.get('_route') == 'articles' ? url('articles') : url('magazine-category', {slug: sync_slug|default(app.request.attributes.get('slug'))}) }}">
<meta property="og:description" content="{{ (_summary != '' ? _summary : _title)|e('html_attr') }}">
<meta property="og:image" content="{{ _og_image|e('html_attr') }}">
<meta property="og:site_name" content="{{ website_name|e('html_attr') }}">
<link rel="canonical" href="{{ app.request.attributes.get('_route') == 'articles' ? url('articles') : url('magazine-category', {slug: app.request.attributes.get('slug')}) }}">
<link rel="canonical" href="{{ app.request.attributes.get('_route') == 'articles' ? url('articles') : url('magazine-category', {slug: sync_slug|default(app.request.attributes.get('slug'))}) }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ _title|e('html_attr') }}">
<meta name="twitter:description" content="{{ (_summary != '' ? _summary : _title)|e('html_attr') }}">
@ -28,7 +31,9 @@ @@ -28,7 +31,9 @@
{% endblock %}
{% block body %}
<div class="category-body" data-magazine-sync-target="pageBody">
<twig:Organisms:CardList :list="list" class="article-list" />
</div>
{% endblock %}
{% block aside %}

3
templates/ux/magazine/category_body.html.twig

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
<div class="category-body" data-magazine-sync-target="pageBody">
<twig:Organisms:CardList :list="list" class="article-list" />
</div>

10
templates/ux/magazine/header_ul.html.twig

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
<ul data-magazine-sync-target="headerNav">
{% for category in cats %}
<li><twig:Molecules:CategoryLink :category="category" /></li>
{% endfor %}
{% if magazine_community_articles %}
<li>
<a href="{{ path('articles') }}">Latest Articles</a>
</li>
{% endif %}
</ul>

5
templates/ux/magazine/home_body.html.twig

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
<div class="home-body" data-magazine-sync-target="pageBody">
{% for item in indices %}
<twig:Organisms:FeaturedList :category="item" class="featured-list"/>
{% endfor %}
</div>
Loading…
Cancel
Save