mirror of
https://github.com/4ian/GDevelop.git
synced 2025-10-15 10:19:04 +00:00
Rework SW
This commit is contained in:
1
newIDE/app/.gitignore
vendored
1
newIDE/app/.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Generated files
|
||||
src/Version/VersionMetadata.js
|
||||
public/service-worker.js
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
@@ -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');
|
||||
}
|
||||
|
@@ -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`;
|
||||
|
@@ -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.
|
||||
*/
|
||||
|
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
260
newIDE/app/src/Utils/BrowserSWIndexedDB.js
Normal file
260
newIDE/app/src/Utils/BrowserSWIndexedDB.js
Normal 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;
|
||||
}
|
||||
};
|
@@ -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;
|
||||
}
|
||||
};
|
@@ -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 => {
|
||||
|
Reference in New Issue
Block a user