From 9f63911c78a865659ff46b2da2d257c5509da79e Mon Sep 17 00:00:00 2001 From: Dmitri Date: Thu, 16 Apr 2026 16:22:07 +0400 Subject: [PATCH] performance optimisation --- backend/src/config.js | 4 + backend/src/services/file.js | 154 +++++++++++++++++- frontend/src/components/RuntimeElement.tsx | 14 ++ .../src/components/RuntimePresentation.tsx | 33 +++- frontend/src/hooks/usePageSwitch.ts | 5 +- frontend/src/hooks/usePreloadOrchestrator.ts | 37 ++++- frontend/src/pages/constructor.tsx | 26 ++- 7 files changed, 259 insertions(+), 14 deletions(-) diff --git a/backend/src/config.js b/backend/src/config.js index 9a6c057..20df96b 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -60,6 +60,10 @@ const config = { clientSecret: process.env.MS_CLIENT_SECRET || '', }, uploadDir: os.tmpdir(), + // Local cache for S3 proxy downloads (improves performance for repeated requests) + s3CacheDir: process.env.S3_CACHE_DIR || path.join(os.tmpdir(), 's3-cache'), + s3CacheEnabled: process.env.S3_CACHE_ENABLED !== 'false', // Enabled by default + s3CacheMaxAge: parseInt(process.env.S3_CACHE_MAX_AGE, 10) || 86400, // 24 hours email: { from: 'Tour Builder Platform ', host: 'email-smtp.us-east-1.amazonaws.com', diff --git a/backend/src/services/file.js b/backend/src/services/file.js index 220df0d..545c885 100644 --- a/backend/src/services/file.js +++ b/backend/src/services/file.js @@ -13,10 +13,59 @@ const fs = require('fs'); const path = require('path'); +const crypto = require('crypto'); const { pipeline } = require('stream/promises'); const { format } = require('util'); const config = require('../config'); + +// ============================================================================ +// S3 Cache Helpers +// ============================================================================ + +/** + * Get the local cache path for an S3 key + */ +const getCachePath = (privateUrl) => { + // Create a safe filename from the URL + const hash = crypto.createHash('md5').update(privateUrl).digest('hex'); + const ext = path.extname(privateUrl) || ''; + return path.join(config.s3CacheDir, `${hash}${ext}`); +}; + +/** + * Check if a cached file exists and is still valid + */ +const getCachedFile = async (cachePath) => { + try { + const stats = await fs.promises.stat(cachePath); + const age = (Date.now() - stats.mtimeMs) / 1000; + if (age < config.s3CacheMaxAge) { + return { stats, valid: true }; + } + return { stats, valid: false }; + } catch { + return { stats: null, valid: false }; + } +}; + +/** + * Ensure cache directory exists + */ +const ensureCacheDir = async () => { + try { + await fs.promises.mkdir(config.s3CacheDir, { recursive: true }); + } catch (err) { + if (err.code !== 'EEXIST') throw err; + } +}; + +/** + * Generate ETag from file stats + */ +const generateETag = (stats) => { + return `"${stats.size.toString(16)}-${stats.mtimeMs.toString(16)}"`; +}; const { logger } = require('../utils/logger'); const S3StorageProvider = require('./file/S3StorageProvider'); const LocalStorageProvider = require('./file/LocalStorageProvider'); @@ -317,24 +366,121 @@ const downloadFile = async (req, res) => { if (provider === 's3') { const s3 = getS3Provider(); + const cachePath = getCachePath(privateUrl); + const useCache = config.s3CacheEnabled; + + // Check local cache first + if (useCache) { + const { stats, valid } = await getCachedFile(cachePath); + + if (valid && stats) { + // Serve from cache + const etag = generateETag(stats); + + // Check If-None-Match for conditional request + if (req.headers['if-none-match'] === etag) { + return res.status(304).end(); + } + + // Set caching headers + res.setHeader('ETag', etag); + res.setHeader( + 'Cache-Control', + `public, max-age=${config.s3CacheMaxAge}`, + ); + res.setHeader('Content-Length', stats.size); + + // Determine content type from extension + const ext = path.extname(privateUrl).toLowerCase(); + const mimeTypes = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.pdf': 'application/pdf', + }; + if (mimeTypes[ext]) { + res.setHeader('Content-Type', mimeTypes[ext]); + } + + log.debug( + { + provider, + privateUrl, + duration: Date.now() - startTime, + cached: true, + }, + 'File served from cache', + ); + + // Stream from cache + return fs.createReadStream(cachePath).pipe(res); + } + } + + // Download from S3 const result = await s3.download(privateUrl, { signal }); if (result.contentType) res.setHeader('Content-Type', result.contentType); if (result.contentLength) res.setHeader('Content-Length', result.contentLength); - if (typeof result.body.pipe === 'function') { + // Add caching headers for browser + res.setHeader( + 'Cache-Control', + `public, max-age=${config.s3CacheMaxAge}`, + ); + + if (useCache && typeof result.body.pipe === 'function') { + // Stream to both response and cache file + await ensureCacheDir(); + const cacheStream = fs.createWriteStream(cachePath); + + // Use pipeline for proper error handling + const { PassThrough } = require('stream'); + const passThrough = new PassThrough(); + + result.body.pipe(passThrough); + passThrough.pipe(res); + passThrough.pipe(cacheStream); + + cacheStream.on('error', (err) => { + log.warn({ err, cachePath }, 'Failed to write to cache'); + }); + } else if (typeof result.body.pipe === 'function') { result.body.pipe(res); } else if (typeof result.body.transformToByteArray === 'function') { const bytes = await result.body.transformToByteArray(); - res.send(Buffer.from(bytes)); + const buffer = Buffer.from(bytes); + + // Cache the buffer + if (useCache) { + await ensureCacheDir(); + fs.promises.writeFile(cachePath, buffer).catch((err) => { + log.warn({ err, cachePath }, 'Failed to write to cache'); + }); + } + + res.send(buffer); } else { res.send(result.body); } log.debug( - { provider, privateUrl, duration: Date.now() - startTime }, - 'File downloaded', + { + provider, + privateUrl, + duration: Date.now() - startTime, + cached: false, + }, + 'File downloaded from S3', ); } else if (provider === 'gcloud') { const { bucket, hash } = getGCloudBucket(); diff --git a/frontend/src/components/RuntimeElement.tsx b/frontend/src/components/RuntimeElement.tsx index 0689028..eb48ed0 100644 --- a/frontend/src/components/RuntimeElement.tsx +++ b/frontend/src/components/RuntimeElement.tsx @@ -15,6 +15,8 @@ import { hasAnyEffects, type ElementEffectProperties, } from '../lib/elementEffects'; +import { isNavigationElementType } from '../lib/elementDefaults'; +import { isBackNavigation } from '../lib/navigationHelpers'; import type { CanvasElement } from '../types/constructor'; interface RuntimeElementProps { @@ -26,6 +28,8 @@ interface RuntimeElementProps { onGalleryCardClick?: (cardIndex: number) => void; /** Letterbox styles for constraining fullscreen elements to canvas bounds */ letterboxStyles?: React.CSSProperties; + /** Whether forward navigation is disabled (neighbor pages not yet preloaded) */ + isForwardNavDisabled?: boolean; } // Clamp position to canvas bounds (0-100%) @@ -38,6 +42,7 @@ const RuntimeElement: React.FC = ({ resolveUrl, onGalleryCardClick, letterboxStyles, + isForwardNavDisabled = false, }) => { // Clamp coordinates to canvas bounds const xPercent = clamp(element.xPercent ?? 50, 0, 100); @@ -97,6 +102,14 @@ const RuntimeElement: React.FC = ({ positionStyle = { ...positionStyle, ...animationStyle }; } + // Compute disabled state for navigation elements + // Forward navigation disabled when neighbor pages not preloaded + // Back navigation always enabled (previous pages are already visited) + const isDisabled = + isNavigationElementType(element.type) && + !isBackNavigation(element) && + isForwardNavDisabled; + return (
= ({ resolveUrl={resolveUrl} onGalleryCardClick={onGalleryCardClick} letterboxStyles={letterboxStyles} + isDisabled={isDisabled} />
); diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx index 6392676..9129306 100644 --- a/frontend/src/components/RuntimePresentation.tsx +++ b/frontend/src/components/RuntimePresentation.tsx @@ -48,6 +48,7 @@ import { resolveNavigationTarget, isTransitionBlocking, isBackNavigation, + isNavigationType, } from '../lib/navigationHelpers'; import type { TransitionPhase } from '../types/presentation'; import type { CanvasElement } from '../types/constructor'; @@ -249,7 +250,8 @@ export default function RuntimePresentation({ const { isFadingIn, resetFadeIn } = useBackgroundTransition({ pageSwitch, fadeIn: { - hasActiveTransition: Boolean(transitionPreview) || pendingTransitionComplete, + hasActiveTransition: + Boolean(transitionPreview) || pendingTransitionComplete, }, }); @@ -401,6 +403,13 @@ export default function RuntimePresentation({ [pages, pageSwitch, resetFadeIn, applyPageSelection], ); + // Compute whether all neighbor backgrounds are ready for instant navigation + const areNeighborBackgroundsReady = + preloadOrchestrator?.areNeighborBackgroundsReady ?? true; + + // Compute disabled state for forward navigation elements + const isForwardNavDisabled = !areNeighborBackgroundsReady; + const handleElementClick = useCallback( (element: CanvasElement) => { // Block navigation while transition is actively playing or buffering @@ -410,6 +419,17 @@ export default function RuntimePresentation({ return; } + // Block forward navigation if neighbor backgrounds not yet preloaded + // Back navigation is always allowed (previous pages are already visited) + if ( + isNavigationType(element.type) && + !isBackNavigation(element) && + !areNeighborBackgroundsReady + ) { + logger.info('Navigation blocked - neighbors not preloaded'); + return; + } + // Get navigation context from hook for history-based back navigation const navContext = getNavigationContext(); @@ -437,7 +457,14 @@ export default function RuntimePresentation({ ); } }, - [navigateToPage, pages, transitionPhase, isBuffering, getNavigationContext], + [ + navigateToPage, + pages, + transitionPhase, + isBuffering, + getNavigationContext, + areNeighborBackgroundsReady, + ], ); // Handler for gallery card clicks @@ -614,7 +641,6 @@ export default function RuntimePresentation({ {/* End page background wrapper */} - {/* Page elements wrapper - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45). UI controls (z-50) remain on top. Fades in together with background. */} @@ -632,6 +658,7 @@ export default function RuntimePresentation({ handleGalleryCardClick(element, cardIndex) } letterboxStyles={letterboxStyles} + isForwardNavDisabled={isForwardNavDisabled} /> ))} diff --git a/frontend/src/hooks/usePageSwitch.ts b/frontend/src/hooks/usePageSwitch.ts index f3e3dd3..c13f614 100644 --- a/frontend/src/hooks/usePageSwitch.ts +++ b/frontend/src/hooks/usePageSwitch.ts @@ -131,10 +131,7 @@ const decodeImage = (url: string): Promise => { img.onload = () => { if (typeof img.decode === 'function') { - img - .decode() - .then(onReady) - .catch(onReady); // Resolve even on decode error + img.decode().then(onReady).catch(onReady); // Resolve even on decode error } else { onReady(); } diff --git a/frontend/src/hooks/usePreloadOrchestrator.ts b/frontend/src/hooks/usePreloadOrchestrator.ts index b5d8deb..4fb42fa 100644 --- a/frontend/src/hooks/usePreloadOrchestrator.ts +++ b/frontend/src/hooks/usePreloadOrchestrator.ts @@ -5,7 +5,7 @@ * Manages the priority queue and orchestrates downloads based on navigation. */ -import { useEffect, useRef, useCallback, useState } from 'react'; +import { useEffect, useRef, useCallback, useState, useMemo } from 'react'; import { useNeighborGraph } from './useNeighborGraph'; import { useNetworkAware } from './useNetworkAware'; import { downloadEventBus } from '../lib/offline/DownloadEventBus'; @@ -60,6 +60,8 @@ interface UsePreloadOrchestratorResult { isUrlPreloaded: (url: string) => Promise; /** Instant lookup - returns decoded blob URL or null */ getReadyBlobUrl: (url: string) => string | null; + /** Whether all neighbor page backgrounds are ready for instant navigation */ + areNeighborBackgroundsReady: boolean; } /** @@ -112,6 +114,38 @@ export function usePreloadOrchestrator( // Use network info for adaptive preloading const { networkInfo } = useNetworkAware(); + // Compute whether all neighbor page backgrounds are ready for instant navigation + // Uses readyUrlsVersion to trigger re-computation when blob URLs become ready + const areNeighborBackgroundsReady = useMemo(() => { + if (!currentPageId || !enabled) return true; // Assume ready if disabled + + // Use existing neighborGraph infrastructure + const neighbors = neighborGraph.getNeighbors(currentPageId, 1); + if (neighbors.length === 0) return true; // No neighbors = ready + + // Check if ALL neighbor background images have READY blob URLs + // IMPORTANT: Use downloadManager.getReadyBlobUrl() NOT preloadedUrls.has() + // preloadedUrls contains URLs that are QUEUED, not URLs that are READY + // We need to check if the blob URL is actually available for instant display + return neighbors.every(({ pageId }) => { + const page = pages.find((p) => p.id === pageId); + if (!page) return true; // Page not found = skip + + // If page has background image, check if blob URL is actually ready + if (page.background_image_url) { + const imageKey = extractStoragePath(page.background_image_url); + // Check if blob URL is ready (not just queued) + if (!downloadManager.getReadyBlobUrl(imageKey)) return false; + } + + // If page has only video background (no image), it can stream - consider ready + // This allows navigation to video-only pages without blocking + + return true; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentPageId, enabled, neighborGraph, pages, readyUrlsVersion]); + // Subscribe to blob URL ready events from DownloadManager useEffect(() => { const unsubscribe = downloadEventBus.on( @@ -751,5 +785,6 @@ export function usePreloadOrchestrator( getCachedBlobUrl, isUrlPreloaded, getReadyBlobUrl, + areNeighborBackgroundsReady, }; } diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index bad89aa..e56b47f 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -1063,6 +1063,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { return; } + // Block forward navigation if neighbor backgrounds not yet preloaded + // Back navigation is always allowed (previous pages are already visited) + if ( + !isBackNavigation(element) && + !preloadOrchestrator.areNeighborBackgroundsReady + ) { + logger.info('Navigation blocked - neighbors not preloaded'); + return; + } + // Use shared navigation helpers const direction = getNavigationDirection(element); @@ -1492,7 +1502,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { > {/* Background wrapper - z-5 keeps it BELOW carousel slide (z-10) */} -
+
{ isElementReadyForCanvasRender(element)); if (!shouldRender) return null; + // Compute disabled state for navigation elements + // Forward navigation disabled when: + // - Element explicitly disabled (navDisabled) + // - Transition is playing or buffering + // - Neighbor backgrounds not yet preloaded (forward only) + const isForwardNav = + isNavigationElementType(element.type) && + !isBackNavigation(element); const isNavDisabled = isNavigationElementType(element.type) && (element.navDisabled || Boolean(transitionPreview) || - isReverseBuffering); + isReverseBuffering || + (isForwardNav && + !preloadOrchestrator.areNeighborBackgroundsReady)); return (