performance optimisation
This commit is contained in:
parent
7ea063390d
commit
9f63911c78
@ -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',
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user