performance optimisation

This commit is contained in:
Dmitri 2026-04-16 16:22:07 +04:00
parent 7ea063390d
commit 9f63911c78
7 changed files with 259 additions and 14 deletions

View File

@ -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 <app@flatlogic.app>',
host: 'email-smtp.us-east-1.amazonaws.com',

View File

@ -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();

View File

@ -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<RuntimeElementProps> = ({
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<RuntimeElementProps> = ({
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 (
<div
className='absolute cursor-pointer'
@ -110,6 +123,7 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
resolveUrl={resolveUrl}
onGalleryCardClick={onGalleryCardClick}
letterboxStyles={letterboxStyles}
isDisabled={isDisabled}
/>
</div>
);

View File

@ -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({
</div>
{/* 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}
/>
))}
</div>

View File

@ -131,10 +131,7 @@ const decodeImage = (url: string): Promise<void> => {
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();
}

View File

@ -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<boolean>;
/** 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,
};
}

View File

@ -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) => {
>
<BackdropPortalProvider>
{/* Background wrapper - z-5 keeps it BELOW carousel slide (z-10) */}
<div className={`absolute inset-0 z-5 ${isFadingIn ? 'animate-crossfade-in' : ''}`}>
<div
className={`absolute inset-0 z-5 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
>
<CanvasBackground
backgroundImageUrl={backgroundImageSrc}
backgroundVideoUrl={backgroundVideoSrc}
@ -1543,11 +1555,21 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
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 (
<CanvasElementComponent