Rework SW

This commit is contained in:
Florian Rival
2025-10-13 11:39:49 +02:00
parent 90cadac9d8
commit b6cad244fc
9 changed files with 411 additions and 505 deletions

View File

@@ -1,5 +1,6 @@
# Generated files
src/Version/VersionMetadata.js
public/service-worker.js
# dependencies
node_modules

View File

@@ -1,15 +1,20 @@
/* eslint-disable no-restricted-globals */
// ============================================================================
// IndexedDB Virtual File System for Local Previews
// ============================================================================
console.log('[ServiceWorker] Service worker file executed');
const DB_NAME = 'gdevelop-local-preview-vfs';
const swURL = new URL(self.location.href);
const isDev = swURL.searchParams.has('dev');
// If updated, also update the BrowserSWIndexedDB module.
const DB_NAME = 'gdevelop-browser-sw-preview';
const STORE_NAME = 'files';
const DB_VERSION = 1;
/**
* Opens the IndexedDB database for local preview files.
* Opens the IndexedDB database for browser SW preview files.
*/
function openPreviewDB() {
return new Promise((resolve, reject) => {
@@ -90,15 +95,15 @@ async function getPreviewFile(path) {
}
/**
* Handles fetch events for local preview files served from IndexedDB.
* Handles fetch events for browser SW preview files served from IndexedDB.
*/
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Check if this is a request for a local preview file
if (url.pathname.startsWith('/local_sw_preview/')) {
const relativePath = url.pathname.replace('/local_sw_preview', '');
console.log('[ServiceWorker] Intercepting local preview request:', url.pathname);
// Check if this is a request for a browser SW preview file
if (url.pathname.startsWith('/browser_sw_preview/')) {
const relativePath = url.pathname.replace('/browser_sw_preview', '');
console.log('[ServiceWorker] Intercepting browser SW preview request:', url.pathname);
event.respondWith((async () => {
try {
@@ -107,7 +112,7 @@ self.addEventListener('fetch', (event) => {
if (!fileRecord) {
console.warn('[ServiceWorker] File not found in IndexedDB:', relativePath);
return new Response('File not found in local preview storage', {
return new Response('File not found in browser SW preview storage', {
status: 404,
headers: {
'Content-Type': 'text/plain',
@@ -130,8 +135,8 @@ self.addEventListener('fetch', (event) => {
}
});
} catch (error) {
console.error('[ServiceWorker] Error serving local preview file:', relativePath, error);
return new Response('Error loading file from local preview storage: ' + error.message, {
console.error('[ServiceWorker] Error serving browser SW preview file:', relativePath, error);
return new Response('Error loading file from browser SW preview storage: ' + error.message, {
status: 500,
headers: {
'Content-Type': 'text/plain',
@@ -145,84 +150,85 @@ self.addEventListener('fetch', (event) => {
}
});
// Log service worker activation
self.addEventListener('install', (event) => {
console.log('[ServiceWorker] Installing service worker with local preview support...');
self.skipWaiting();
console.log('[ServiceWorker] Installing service worker...');
if (isDev) {
// In development, immediately use a new service worker.
self.skipWaiting();
}
});
self.addEventListener('activate', (event) => {
console.log('[ServiceWorker] Activating service worker with local preview support...');
event.waitUntil(self.clients.claim());
console.log('[ServiceWorker] Activating service worker...');
if (isDev) {
event.waitUntil(self.clients.claim());
}
});
// ============================================================================
// Standard Workbox Configuration
// ============================================================================
if (typeof importScripts === 'function') {
importScripts(
'https://storage.googleapis.com/workbox-cdn/releases/3.5.0/workbox-sw.js'
// TODO: remove this check
// eslint-disable-next-line no-undef
importScripts(
'https://storage.googleapis.com/workbox-cdn/releases/3.5.0/workbox-sw.js'
);
/* global workbox */
if (workbox) {
console.log('[ServiceWorker] Workbox loaded successfully');
// Will be replaced by make-service-worker.js to include the proper version.
const VersionMetadata = {};
// Contrary to other static assets (JS, CSS, HTML), libGD.js/wasm are not
// versioned in their filenames. Instead, we version using a query string
// (see src/index.js where it's loaded with the same query string).
workbox.precaching.precacheAndRoute([
{
url: `libGD.js?cache-buster=${VersionMetadata.versionWithHash}`,
revision: null, // Revision is null because versioning included in the URL.
},
{
url: `libGD.wasm?cache-buster=${VersionMetadata.versionWithHash}`,
revision: null, // Revision is null because versioning included in the URL.
},
]);
/* injection point for manifest files. */
workbox.precaching.precacheAndRoute([]);
/* custom cache rules*/
workbox.routing.registerNavigationRoute('/index.html', {
blacklist: [/^\/_/, /\/[^\/]+\.[^\/]+$/, /^\/browser_sw_preview\//],
});
// Cache resources from GDevelop cloudfront server (CORS enabled).
workbox.routing.registerRoute(
/https:\/\/resources\.gdevelop-app\.com\/.*$/,
workbox.strategies.networkFirst({
cacheName: 'gdevelop-resources-cache',
plugins: [
new workbox.expiration.Plugin({
maxEntries: 500,
}),
],
})
);
/* global workbox */
if (workbox) {
console.log('[ServiceWorker] Workbox loaded successfully');
// Will be replaced by make-service-worker.js to include the proper version.
const VersionMetadata = {};
// Contrary to other static assets (JS, CSS, HTML), libGD.js/wasm are not
// versioned in their filenames. Instead, we version using a query string
// (see src/index.js where it's loaded with the same query string).
workbox.precaching.precacheAndRoute([
{
url: `libGD.js?cache-buster=${VersionMetadata.versionWithHash}`,
revision: null, // Revision is null because versioning included in the URL.
},
{
url: `libGD.wasm?cache-buster=${VersionMetadata.versionWithHash}`,
revision: null, // Revision is null because versioning included in the URL.
},
]);
/* injection point for manifest files. */
workbox.precaching.precacheAndRoute([]);
/* custom cache rules*/
workbox.routing.registerNavigationRoute('/index.html', {
blacklist: [/^\/_/, /\/[^\/]+\.[^\/]+$/, /^\/local_sw_preview\//],
});
// Cache resources from GDevelop cloudfront server (CORS enabled).
workbox.routing.registerRoute(
/https:\/\/resources\.gdevelop-app\.com\/.*$/,
workbox.strategies.networkFirst({
cacheName: 'gdevelop-resources-cache',
plugins: [
new workbox.expiration.Plugin({
maxEntries: 500,
}),
],
})
);
// TODO: this should be useless?
workbox.routing.registerRoute(
/\.(?:png|gif|jpg|jpeg)$/,
workbox.strategies.networkFirst({
cacheName: 'images',
plugins: [
new workbox.expiration.Plugin({
maxEntries: 150,
}),
],
})
);
} else {
console.log('[ServiceWorker] Workbox could not be loaded - no offline support');
}
// TODO: this should be useless?
workbox.routing.registerRoute(
/\.(?:png|gif|jpg|jpeg)$/,
workbox.strategies.networkFirst({
cacheName: 'images',
plugins: [
new workbox.expiration.Plugin({
maxEntries: 150,
}),
],
})
);
} else {
console.log(
'[ServiceWorker] importScripts does not exist on this browser - no offline support'
);
console.log('[ServiceWorker] Workbox could not be loaded - no offline support');
}

View File

@@ -3,10 +3,11 @@ import {
type EventsFunctionCodeWriter,
type EventsFunctionCodeWriterCallbacks,
} from '..';
import { putFile } from '../../Utils/LocalFileIndexedDB';
import { putFile } from '../../Utils/BrowserSWIndexedDB';
import { makeTimestampedId } from '../../Utils/TimestampedId';
import slugs from 'slugs';
import debounce from 'lodash/debounce';
import { getBrowserSWPreviewBaseUrl } from '../../Utils/BrowserSWIndexedDB';
let batchedWrites: Array<{
path: string,
@@ -70,14 +71,6 @@ const writeFileInNextBatch = (path: string, content: string) => {
});
};
/**
* Gets the base URL for local service worker previews.
*/
const getLocalPreviewBaseUrl = (): string => {
const origin = window.location.origin;
return `${origin}/local_sw_preview`;
};
/**
* Create the EventsFunctionCodeWriter that writes generated code for events functions
* to IndexedDB for service worker serving.
@@ -86,7 +79,7 @@ export const makeBrowserSWEventsFunctionCodeWriter = ({
onWriteFile,
}: EventsFunctionCodeWriterCallbacks): EventsFunctionCodeWriter => {
const prefix = makeTimestampedId();
const baseUrl = getLocalPreviewBaseUrl();
const baseUrl = getBrowserSWPreviewBaseUrl();
const getPathFor = (codeNamespace: string) => {
return `${baseUrl}/${prefix}/${slugs(codeNamespace)}.js`;

View File

@@ -38,10 +38,6 @@ const isURL = (filename: string) => {
// Search for "pathPosix" in the codebase for other places where this is used.
const pathPosix = path.posix || path;
// TODO: Merge BrowserS3FileSystem into this? The way URLs are handled
// is different though (BrowserS3FileSystem is not downloading anything
// from URLs, while this file system does download files).
/**
* An in-memory "file system" that can be used for GDevelop exports.
*/

View File

@@ -1,6 +1,6 @@
// @flow
import path from 'path-browserify';
import { putFile } from '../../Utils/LocalFileIndexedDB';
import { deleteFilesWithPrefix, putFile } from '../../Utils/BrowserSWIndexedDB';
const gd: libGDevelop = global.gd;
export type TextFileDescriptor = {|
@@ -82,21 +82,24 @@ export default class BrowserSWFileSystem {
/**
* Uploads all pending files to IndexedDB.
* This replaces the S3 upload functionality with local IndexedDB storage.
*/
uploadPendingObjects = async () => {
applyPendingOperations = async () => {
try {
console.log(
`[BrowserSWFileSystem] Storing ${this._pendingFiles.length} files in IndexedDB for preview...`
`[BrowserSWFileSystem] Storing ${
this._pendingFiles.length
} files in IndexedDB for preview...`
);
const uploadPromises = this._pendingFiles.map(async (file) => {
const uploadPromises = this._pendingFiles.map(async file => {
const fullPath = `/${file.path}`; // TODO
const encoder = new TextEncoder();
const bytes = encoder.encode(file.content).buffer;
console.log(
`[BrowserSWFileSystem] Storing file: ${fullPath} (${bytes.byteLength} bytes, ${file.contentType})`
`[BrowserSWFileSystem] Storing file: ${fullPath} (${
bytes.byteLength
} bytes, ${file.contentType})`
);
await putFile(fullPath, bytes, file.contentType);
@@ -105,10 +108,15 @@ export default class BrowserSWFileSystem {
await Promise.all(uploadPromises);
console.log(
`[BrowserSWFileSystem] Successfully stored all ${this._pendingFiles.length} preview files in IndexedDB.`
`[BrowserSWFileSystem] Successfully stored all ${
this._pendingFiles.length
} preview files in IndexedDB.`
);
} catch (error) {
console.error("[BrowserSWFileSystem] Can't store all files in IndexedDB:", error);
console.error(
"[BrowserSWFileSystem] Can't store all files in IndexedDB:",
error
);
throw error;
}
};
@@ -123,7 +131,18 @@ export default class BrowserSWFileSystem {
};
clearDir = (path: string) => {
// Assume path is cleared.
// TODO: add to a pending operation list so we ensure it's executed.
console.info(`[BrowserSWFileSystem] Clearing directory: ${path}...`);
deleteFilesWithPrefix(path)
.then(() => {
console.info(`[BrowserSWFileSystem] Cleared directory: ${path}`);
})
.catch(error => {
console.error(
`[BrowserSWFileSystem] Error clearing directory: ${path}`,
error
);
});
};
getTempDir = () => {
@@ -170,7 +189,12 @@ export default class BrowserSWFileSystem {
return true;
}
console.warn('[BrowserSWFileSystem] Copy not done from', source, 'to', dest);
console.warn(
'[BrowserSWFileSystem] Copy not done from',
source,
'to',
dest
);
return true;
};
@@ -197,7 +221,9 @@ export default class BrowserSWFileSystem {
if (!!this._indexedFilesContent[file])
return this._indexedFilesContent[file].text;
console.error(`[BrowserSWFileSystem] Unknown file ${file}, returning an empty string`);
console.error(
`[BrowserSWFileSystem] Unknown file ${file}, returning an empty string`
);
return '';
};
@@ -223,4 +249,4 @@ export default class BrowserSWFileSystem {
// Assume all files asked for exist.
return true;
};
}
}

View File

@@ -19,6 +19,7 @@ import { displayBlackLoadingScreenOrThrow } from '../../../Utils/BrowserExternal
import { getGDevelopResourceJwtToken } from '../../../Utils/GDevelopServices/Project';
import { isNativeMobileApp } from '../../../Utils/Platform';
import { getIDEVersionWithHash } from '../../../Version';
import { getBrowserSWPreviewBaseUrl } from '../../../Utils/BrowserSWIndexedDB';
const gd: libGDevelop = global.gd;
type State = {|
@@ -57,16 +58,6 @@ export const immediatelyOpenNewPreviewWindow = (
return previewWindow;
};
/**
* Gets the base URL for local service worker previews.
* This URL should be handled by the service worker to serve files from IndexedDB.
*/
const getLocalPreviewBaseUrl = (): string => {
// Use the current origin to ensure the service worker can intercept requests
const origin = window.location.origin;
return `${origin}/local_sw_preview`;
};
export default class BrowserSWPreviewLauncher extends React.Component<
PreviewLauncherProps,
State
@@ -87,10 +78,13 @@ export default class BrowserSWPreviewLauncher extends React.Component<
console.info('[BrowserSWPreviewLauncher] GDJS found in', gdjsRoot);
const prefix = makeTimestampedId();
const baseUrl = getLocalPreviewBaseUrl();
const baseUrl = getBrowserSWPreviewBaseUrl();
const outputDir = `${baseUrl}/${prefix}`;
console.log('[BrowserSWPreviewLauncher] Preview will be served from:', outputDir);
console.log(
'[BrowserSWPreviewLauncher] Preview will be served from:',
outputDir
);
const browserSWFileSystem = new BrowserSWFileSystem({
filesContent,
@@ -232,14 +226,18 @@ export default class BrowserSWPreviewLauncher extends React.Component<
if (gdevelopResourceToken)
previewExportOptions.setGDevelopResourceToken(gdevelopResourceToken);
console.log('[BrowserSWPreviewLauncher] Exporting project for preview...');
console.log(
'[BrowserSWPreviewLauncher] Exporting project for preview...'
);
exporter.exportProjectForPixiPreview(previewExportOptions);
previewExportOptions.delete();
exporter.delete();
// Store files in IndexedDB instead of uploading to S3
console.log('[BrowserSWPreviewLauncher] Storing preview files in IndexedDB...');
await browserSWFileSystem.uploadPendingObjects();
console.log(
'[BrowserSWPreviewLauncher] Storing preview files in IndexedDB...'
);
await browserSWFileSystem.applyPendingOperations();
// Change the HTML file displayed by the preview window so that it starts loading
// the game.
@@ -279,7 +277,10 @@ export default class BrowserSWPreviewLauncher extends React.Component<
console.log('[BrowserSWPreviewLauncher] Preview launched successfully!');
} catch (error) {
console.error('[BrowserSWPreviewLauncher] Error launching preview:', error);
console.error(
'[BrowserSWPreviewLauncher] Error launching preview:',
error
);
this.setState({
error,
});
@@ -308,4 +309,4 @@ export default class BrowserSWPreviewLauncher extends React.Component<
return null;
}
}
}

View File

@@ -0,0 +1,260 @@
// @flow
/**
* A utility module for managing local game preview files in IndexedDB.
* This provides a clean, promise-based interface for storing and retrieving
* game files that will be served by the service worker.
*/
// If updated, also update the service worker template.
const DB_NAME = 'gdevelop-browser-sw-preview';
const STORE_NAME = 'files';
const DB_VERSION = 1;
type FileRecord = {|
bytes: ArrayBuffer,
contentType: string,
|};
let dbInstance: ?IDBDatabase = null;
/**
* Gets the base URL for browser service worker previews.
* This URL should be handled by the service worker to serve files from IndexedDB.
*/
export const getBrowserSWPreviewBaseUrl = (): string => {
// Use the current origin to ensure the service worker can intercept requests
const origin = window.location.origin;
return `${origin}/browser_sw_preview`;
};
/**
* Opens or returns the existing IndexedDB database connection.
* Handles database upgrades and version management.
*/
export const openDatabase = (): Promise<IDBDatabase> => {
if (dbInstance && dbInstance.version === DB_VERSION) {
return Promise.resolve(dbInstance);
}
return new Promise((resolve, reject) => {
try {
// $FlowFixMe - indexedDB is available in all browsers
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
const error = request.error || new Error('Failed to open IndexedDB');
console.error('[BrowserSWIndexedDB] Error opening database:', error);
reject(error);
};
request.onsuccess = () => {
dbInstance = request.result;
console.log('[BrowserSWIndexedDB] Database opened successfully');
// Handle unexpected close
dbInstance.onclose = () => {
console.warn(
'[BrowserSWIndexedDB] Database connection closed unexpectedly'
);
dbInstance = null;
};
// Handle version change (e.g., if another tab upgrades the DB)
dbInstance.onversionchange = () => {
console.warn(
'[BrowserSWIndexedDB] Database version changed, closing connection'
);
if (dbInstance) {
dbInstance.close();
dbInstance = null;
}
};
resolve(dbInstance);
};
request.onupgradeneeded = event => {
console.log('[BrowserSWIndexedDB] Upgrading database schema...');
const db = request.result;
// Create object store if it doesn't exist
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
console.log('[BrowserSWIndexedDB] Created object store:', STORE_NAME);
}
};
request.onblocked = () => {
console.warn(
'[BrowserSWIndexedDB] Database upgrade blocked by another connection'
);
reject(
new Error(
'Database upgrade blocked. Please close other tabs using this application.'
)
);
};
} catch (error) {
console.error(
'[BrowserSWIndexedDB] Exception while opening database:',
error
);
reject(error);
}
});
};
/**
* Stores a file in IndexedDB.
* @param path - The virtual path of the file (e.g., '/browser_sw_preview/session-123/index.html')
* @param bytes - The file content as ArrayBuffer
* @param contentType - The MIME type of the file
*/
export const putFile = async (
path: string,
bytes: ArrayBuffer,
contentType: string
): Promise<void> => {
try {
const db = await openDatabase();
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction(STORE_NAME, 'readwrite');
transaction.onerror = () => {
const error = transaction.error || new Error('Transaction failed');
console.error(
'[BrowserSWIndexedDB] Transaction error while putting file:',
path,
error
);
reject(error);
};
transaction.oncomplete = () => {
console.log('[BrowserSWIndexedDB] File stored successfully:', path);
resolve();
};
// TODO: add onabort?
const objectStore = transaction.objectStore(STORE_NAME);
const record: FileRecord = { bytes, contentType };
const request = objectStore.put(record, path);
request.onerror = () => {
const error = request.error || new Error('Put operation failed');
console.error(
'[BrowserSWIndexedDB] Error storing file:',
path,
error
);
reject(error);
};
} catch (error) {
console.error(
'[BrowserSWIndexedDB] Exception during put operation:',
path,
error
);
reject(error);
}
});
} catch (error) {
console.error('[BrowserSWIndexedDB] Failed to put file:', path, error);
throw error;
}
};
/**
* Deletes all files with a given path prefix.
* Useful for cleaning up old preview sessions.
* @param pathPrefix - The path prefix to match (e.g., '/browser_sw_preview/session-123/')
*/
export const deleteFilesWithPrefix = async (
pathPrefix: string
): Promise<number> => {
try {
const db = await openDatabase();
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction(STORE_NAME, 'readwrite');
let deletedCount = 0;
transaction.onerror = () => {
const error = transaction.error || new Error('Transaction failed');
console.error(
'[BrowserSWIndexedDB] Transaction error while deleting files with prefix:',
pathPrefix,
error
);
reject(error);
};
transaction.oncomplete = () => {
console.log(
'[BrowserSWIndexedDB] Deleted',
deletedCount,
'files with prefix:',
pathPrefix
);
resolve(deletedCount);
};
const objectStore = transaction.objectStore(STORE_NAME);
const request = objectStore.openCursor();
request.onsuccess = event => {
const cursor = event.target.result;
if (cursor) {
const key = cursor.key;
if (typeof key === 'string' && key.startsWith(pathPrefix)) {
cursor.delete();
deletedCount++;
}
cursor.continue();
}
};
request.onerror = () => {
const error = request.error || new Error('Cursor operation failed');
console.error(
'[BrowserSWIndexedDB] Error during cursor operation:',
error
);
reject(error);
};
} catch (error) {
console.error(
'[BrowserSWIndexedDB] Exception during delete with prefix:',
pathPrefix,
error
);
reject(error);
}
});
} catch (error) {
console.error(
'[BrowserSWIndexedDB] Failed to delete files with prefix:',
pathPrefix,
error
);
throw error;
}
};
/**
* Closes the database connection.
* Should be called when the application is shutting down.
*/
export const closeDatabase = (): void => {
if (dbInstance) {
console.log('[BrowserSWIndexedDB] Closing database connection');
dbInstance.close();
dbInstance = null;
}
};

View File

@@ -1,338 +0,0 @@
// @flow
/**
* A utility module for managing local game preview files in IndexedDB.
* This provides a clean, promise-based interface for storing and retrieving
* game files that will be served by the service worker.
*/
const DB_NAME = 'gdevelop-local-preview-vfs';
const STORE_NAME = 'files';
const DB_VERSION = 1;
export type FileRecord = {|
bytes: ArrayBuffer,
contentType: string,
|};
let dbInstance: ?IDBDatabase = null;
/**
* Opens or returns the existing IndexedDB database connection.
* Handles database upgrades and version management.
*/
export const openDatabase = (): Promise<IDBDatabase> => {
if (dbInstance && dbInstance.version === DB_VERSION) {
return Promise.resolve(dbInstance);
}
return new Promise((resolve, reject) => {
try {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
const error = request.error || new Error('Failed to open IndexedDB');
console.error('[LocalFileIndexedDB] Error opening database:', error);
reject(error);
};
request.onsuccess = () => {
dbInstance = request.result;
console.log('[LocalFileIndexedDB] Database opened successfully');
// Handle unexpected close
dbInstance.onclose = () => {
console.warn('[LocalFileIndexedDB] Database connection closed unexpectedly');
dbInstance = null;
};
// Handle version change (e.g., if another tab upgrades the DB)
dbInstance.onversionchange = () => {
console.warn('[LocalFileIndexedDB] Database version changed, closing connection');
if (dbInstance) {
dbInstance.close();
dbInstance = null;
}
};
resolve(dbInstance);
};
request.onupgradeneeded = (event) => {
console.log('[LocalFileIndexedDB] Upgrading database schema...');
const db = request.result;
// Create object store if it doesn't exist
if (!db.objectStoreNames.contains(STORE_NAME)) {
const objectStore = db.createObjectStore(STORE_NAME);
console.log('[LocalFileIndexedDB] Created object store:', STORE_NAME);
}
};
request.onblocked = () => {
console.warn('[LocalFileIndexedDB] Database upgrade blocked by another connection');
reject(new Error('Database upgrade blocked. Please close other tabs using this application.'));
};
} catch (error) {
console.error('[LocalFileIndexedDB] Exception while opening database:', error);
reject(error);
}
});
};
/**
* Stores a file in IndexedDB.
* @param path - The virtual path of the file (e.g., '/local_sw_preview/session-123/index.html')
* @param bytes - The file content as ArrayBuffer
* @param contentType - The MIME type of the file
*/
export const putFile = async (
path: string,
bytes: ArrayBuffer,
contentType: string
): Promise<void> => {
try {
const db = await openDatabase();
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction(STORE_NAME, 'readwrite');
transaction.onerror = () => {
const error = transaction.error || new Error('Transaction failed');
console.error('[LocalFileIndexedDB] Transaction error while putting file:', path, error);
reject(error);
};
transaction.oncomplete = () => {
console.log('[LocalFileIndexedDB] File stored successfully:', path);
resolve();
};
const objectStore = transaction.objectStore(STORE_NAME);
const record: FileRecord = { bytes, contentType };
const request = objectStore.put(record, path);
request.onerror = () => {
const error = request.error || new Error('Put operation failed');
console.error('[LocalFileIndexedDB] Error storing file:', path, error);
reject(error);
};
} catch (error) {
console.error('[LocalFileIndexedDB] Exception during put operation:', path, error);
reject(error);
}
});
} catch (error) {
console.error('[LocalFileIndexedDB] Failed to put file:', path, error);
throw error;
}
};
/**
* Retrieves a file from IndexedDB.
* @param path - The virtual path of the file
* @returns The file record or null if not found
*/
export const getFile = async (path: string): Promise<?FileRecord> => {
try {
const db = await openDatabase();
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction(STORE_NAME, 'readonly');
transaction.onerror = () => {
const error = transaction.error || new Error('Transaction failed');
console.error('[LocalFileIndexedDB] Transaction error while getting file:', path, error);
reject(error);
};
const objectStore = transaction.objectStore(STORE_NAME);
const request = objectStore.get(path);
request.onsuccess = () => {
const result = request.result;
if (result) {
console.log('[LocalFileIndexedDB] File retrieved:', path);
} else {
console.warn('[LocalFileIndexedDB] File not found:', path);
}
resolve(result || null);
};
request.onerror = () => {
const error = request.error || new Error('Get operation failed');
console.error('[LocalFileIndexedDB] Error retrieving file:', path, error);
reject(error);
};
} catch (error) {
console.error('[LocalFileIndexedDB] Exception during get operation:', path, error);
reject(error);
}
});
} catch (error) {
console.error('[LocalFileIndexedDB] Failed to get file:', path, error);
throw error;
}
};
/**
* Deletes a file from IndexedDB.
* @param path - The virtual path of the file to delete
*/
export const deleteFile = async (path: string): Promise<void> => {
try {
const db = await openDatabase();
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction(STORE_NAME, 'readwrite');
transaction.onerror = () => {
const error = transaction.error || new Error('Transaction failed');
console.error('[LocalFileIndexedDB] Transaction error while deleting file:', path, error);
reject(error);
};
transaction.oncomplete = () => {
console.log('[LocalFileIndexedDB] File deleted successfully:', path);
resolve();
};
const objectStore = transaction.objectStore(STORE_NAME);
const request = objectStore.delete(path);
request.onerror = () => {
const error = request.error || new Error('Delete operation failed');
console.error('[LocalFileIndexedDB] Error deleting file:', path, error);
reject(error);
};
} catch (error) {
console.error('[LocalFileIndexedDB] Exception during delete operation:', path, error);
reject(error);
}
});
} catch (error) {
console.error('[LocalFileIndexedDB] Failed to delete file:', path, error);
throw error;
}
};
/**
* Deletes all files with a given path prefix.
* Useful for cleaning up old preview sessions.
* @param pathPrefix - The path prefix to match (e.g., '/local_sw_preview/session-123/')
*/
export const deleteFilesWithPrefix = async (pathPrefix: string): Promise<number> => {
try {
const db = await openDatabase();
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction(STORE_NAME, 'readwrite');
let deletedCount = 0;
transaction.onerror = () => {
const error = transaction.error || new Error('Transaction failed');
console.error('[LocalFileIndexedDB] Transaction error while deleting files with prefix:', pathPrefix, error);
reject(error);
};
transaction.oncomplete = () => {
console.log('[LocalFileIndexedDB] Deleted', deletedCount, 'files with prefix:', pathPrefix);
resolve(deletedCount);
};
const objectStore = transaction.objectStore(STORE_NAME);
const request = objectStore.openCursor();
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const key = cursor.key;
if (typeof key === 'string' && key.startsWith(pathPrefix)) {
cursor.delete();
deletedCount++;
}
cursor.continue();
}
};
request.onerror = () => {
const error = request.error || new Error('Cursor operation failed');
console.error('[LocalFileIndexedDB] Error during cursor operation:', error);
reject(error);
};
} catch (error) {
console.error('[LocalFileIndexedDB] Exception during delete with prefix:', pathPrefix, error);
reject(error);
}
});
} catch (error) {
console.error('[LocalFileIndexedDB] Failed to delete files with prefix:', pathPrefix, error);
throw error;
}
};
/**
* Gets all file paths stored in the database.
* Useful for debugging and cleanup operations.
*/
export const getAllFilePaths = async (): Promise<Array<string>> => {
try {
const db = await openDatabase();
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction(STORE_NAME, 'readonly');
const paths: Array<string> = [];
transaction.onerror = () => {
const error = transaction.error || new Error('Transaction failed');
console.error('[LocalFileIndexedDB] Transaction error while getting all paths:', error);
reject(error);
};
const objectStore = transaction.objectStore(STORE_NAME);
const request = objectStore.getAllKeys();
request.onsuccess = () => {
const keys = request.result;
keys.forEach(key => {
if (typeof key === 'string') {
paths.push(key);
}
});
console.log('[LocalFileIndexedDB] Retrieved', paths.length, 'file paths');
resolve(paths);
};
request.onerror = () => {
const error = request.error || new Error('getAllKeys operation failed');
console.error('[LocalFileIndexedDB] Error getting all keys:', error);
reject(error);
};
} catch (error) {
console.error('[LocalFileIndexedDB] Exception during getAllKeys:', error);
reject(error);
}
});
} catch (error) {
console.error('[LocalFileIndexedDB] Failed to get all file paths:', error);
throw error;
}
};
/**
* Closes the database connection.
* Should be called when the application is shutting down.
*/
export const closeDatabase = (): void => {
if (dbInstance) {
console.log('[LocalFileIndexedDB] Closing database connection');
dbInstance.close();
dbInstance = null;
}
};

View File

@@ -1,15 +1,4 @@
// @flow
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
@@ -21,7 +10,7 @@ const isLocalhost = Boolean(
)
);
const PUBLIC_URL = process.env.PUBLIC_URL;
const PUBLIC_URL: string = process.env.PUBLIC_URL || '';
const isDev = process.env.NODE_ENV !== 'production';
export function registerServiceWorker(config) {
@@ -45,14 +34,18 @@ export function registerServiceWorker(config) {
}
window.addEventListener('load', () => {
const swUrl = `${PUBLIC_URL}/service-worker.js?dev=${Date.now()}`;
// Use a cache-buster for development so that the service worker is
// always reloaded when the app is reloaded.
const swUrl = isDev
? `${PUBLIC_URL}/service-worker.js?dev=${Date.now()}`
: `${PUBLIC_URL}/service-worker.js`;
registerValidSW(swUrl, config);
if (isLocalhost) {
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then((registration) => {
navigator.serviceWorker.ready.then(registration => {
if (isDev) registration.update(); // TODO
console.log(
'This web app is being served cache-first by a service ' +
@@ -62,9 +55,7 @@ export function registerServiceWorker(config) {
}
});
} else {
console.log(
'Service Worker disabled - TODO: fallback to S3?'
);
console.log('Service Worker disabled - TODO: fallback to S3?');
}
}
@@ -112,36 +103,6 @@ function registerValidSW(swUrl, config) {
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' },
})
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {