Compare commits

...

2 Commits

Author SHA1 Message Date
Florian Rival
a05b0cd0ef [WIP] Refactoring 2025-10-15 00:44:46 +02:00
Florian Rival
c87b83806e [WIP] Make hot reload work in web-app 2025-10-14 21:26:30 +02:00
12 changed files with 209 additions and 249 deletions

View File

@@ -1,6 +1,7 @@
/* eslint-disable no-restricted-globals */ /* eslint-disable no-restricted-globals */
// ============================================================================ // ============================================================================
// IndexedDB Virtual File System for Local Previews // Browser Service Worker powered Preview ("Browser SW Preview"), using IndexedDB.
// ============================================================================ // ============================================================================
console.log('[ServiceWorker] Service worker file executed'); console.log('[ServiceWorker] Service worker file executed');
@@ -16,7 +17,7 @@ const DB_VERSION = 1;
/** /**
* Opens the IndexedDB database for browser SW preview files. * Opens the IndexedDB database for browser SW preview files.
*/ */
function openPreviewDB() { function openBrowserSWPreviewDB() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const request = indexedDB.open(DB_NAME, DB_VERSION); const request = indexedDB.open(DB_NAME, DB_VERSION);
@@ -49,43 +50,57 @@ function openPreviewDB() {
} }
/** /**
* Retrieves a file from IndexedDB. * Retrieves a preview file from IndexedDB.
*/ */
async function getPreviewFile(path) { async function getBrowserSWPreviewFile(path) {
try { try {
const db = await openPreviewDB(); const db = await openBrowserSWPreviewDB();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let settled = false;
const safeResolve = (v) => { if (!settled) { settled = true; resolve(v); } };
const safeReject = (e) => { if (!settled) { settled = true; reject(e); } };
try { try {
const transaction = db.transaction(STORE_NAME, 'readonly'); // Sanity-check the store exists (avoids InvalidStateError).
if (!db.objectStoreNames.contains(STORE_NAME)) {
const err = new Error(`Object store "${STORE_NAME}" not found`);
console.error('[ServiceWorker] Missing object store while getting file:', path, err);
return safeReject(err);
}
transaction.onerror = () => { const tx = db.transaction(STORE_NAME, 'readonly');
const error = transaction.error || new Error('Transaction failed');
// If the transaction aborts (quota, deadlock, explicit abort, etc.), reject.
tx.onabort = () => {
const error = tx.error || new Error('Transaction aborted');
console.error('[ServiceWorker] Transaction aborted while getting file:', path, error);
safeReject(error);
};
// `onerror` at the transaction level can fire even if request handlers didnt.
tx.onerror = () => {
const error = tx.error || new Error('Transaction failed');
console.error('[ServiceWorker] Transaction error while getting file:', path, error); console.error('[ServiceWorker] Transaction error while getting file:', path, error);
reject(error); safeReject(error);
}; };
const objectStore = transaction.objectStore(STORE_NAME); const store = tx.objectStore(STORE_NAME);
const request = objectStore.get(path); const req = store.get(path);
request.onsuccess = () => { req.onsuccess = () => {
const result = request.result; const result = req.result;
if (result) { safeResolve(result || null);
console.log('[ServiceWorker] File retrieved from IndexedDB:', path, '(' + result.bytes.byteLength + ' bytes)');
} else {
console.warn('[ServiceWorker] File not found in IndexedDB:', path);
}
resolve(result || null);
}; };
request.onerror = () => { req.onerror = () => {
const error = request.error || new Error('Get operation failed'); const error = req.error || new Error('Get operation failed');
console.error('[ServiceWorker] Error retrieving file from IndexedDB:', path, error); console.error('[ServiceWorker] Error retrieving file from IndexedDB:', path, error);
reject(error); safeReject(error);
}; };
} catch (error) { } catch (error) {
console.error('[ServiceWorker] Exception during get operation:', path, error); console.error('[ServiceWorker] Exception during get operation:', path, error);
reject(error); safeReject(error);
} }
}); });
} catch (error) { } catch (error) {
@@ -103,12 +118,11 @@ self.addEventListener('fetch', (event) => {
// Check if this is a request for a browser SW preview file // Check if this is a request for a browser SW preview file
if (url.pathname.startsWith('/browser_sw_preview/')) { if (url.pathname.startsWith('/browser_sw_preview/')) {
const relativePath = url.pathname.replace('/browser_sw_preview', ''); const relativePath = url.pathname.replace('/browser_sw_preview', '');
console.log('[ServiceWorker] Intercepting browser SW preview request:', url.pathname);
event.respondWith((async () => { event.respondWith((async () => {
try { try {
// Try to get the file from IndexedDB // Try to get the file from IndexedDB
const fileRecord = await getPreviewFile(relativePath); const fileRecord = await getBrowserSWPreviewFile(relativePath);
if (!fileRecord) { if (!fileRecord) {
console.warn('[ServiceWorker] File not found in IndexedDB:', relativePath); console.warn('[ServiceWorker] File not found in IndexedDB:', relativePath);
@@ -121,7 +135,6 @@ self.addEventListener('fetch', (event) => {
} }
// Return the file with appropriate headers // Return the file with appropriate headers
console.log('[ServiceWorker] Serving file from IndexedDB:', relativePath, 'Content-Type:', fileRecord.contentType);
return new Response(fileRecord.bytes, { return new Response(fileRecord.bytes, {
status: 200, status: 200,
headers: { headers: {
@@ -167,14 +180,16 @@ self.addEventListener('activate', (event) => {
}); });
// ============================================================================ // ============================================================================
// Standard Workbox Configuration // Standard Workbox Configuration (for "semi-offline"/caching of GDevelop static files and resources)
// ============================================================================ // ============================================================================
// TODO: remove this check self.__WB_DISABLE_DEV_LOGS = true;
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
importScripts( importScripts(
'https://storage.googleapis.com/workbox-cdn/releases/3.5.0/workbox-sw.js' 'https://storage.googleapis.com/workbox-cdn/releases/3.5.0/workbox-sw.js'
); );
/* global workbox */ /* global workbox */
if (workbox) { if (workbox) {
console.log('[ServiceWorker] Workbox loaded successfully'); console.log('[ServiceWorker] Workbox loaded successfully');

View File

@@ -3,11 +3,13 @@ import {
type EventsFunctionCodeWriter, type EventsFunctionCodeWriter,
type EventsFunctionCodeWriterCallbacks, type EventsFunctionCodeWriterCallbacks,
} from '..'; } from '..';
import { putFile } from '../../Utils/BrowserSWIndexedDB'; import {
import { makeTimestampedId } from '../../Utils/TimestampedId'; deleteFilesWithPrefix,
putFile,
getBrowserSWPreviewBaseUrl,
} from '../../ExportAndShare/BrowserExporters/BrowserSWPreviewLauncher/BrowserSWPreviewIndexedDB';
import slugs from 'slugs'; import slugs from 'slugs';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { getBrowserSWPreviewBaseUrl } from '../../Utils/BrowserSWIndexedDB';
let batchedWrites: Array<{ let batchedWrites: Array<{
path: string, path: string,
@@ -41,7 +43,6 @@ const flushBatchedWrites = debounce(async () => {
results.forEach((result, index) => { results.forEach((result, index) => {
const write = writes[index]; const write = writes[index];
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') {
console.log(`[BrowserSWEventsFunctionCodeWriter] Stored: ${write.path}`);
write.onSuccess(); write.onSuccess();
} else { } else {
console.error( console.error(
@@ -78,11 +79,25 @@ const writeFileInNextBatch = (path: string, content: string) => {
export const makeBrowserSWEventsFunctionCodeWriter = ({ export const makeBrowserSWEventsFunctionCodeWriter = ({
onWriteFile, onWriteFile,
}: EventsFunctionCodeWriterCallbacks): EventsFunctionCodeWriter => { }: EventsFunctionCodeWriterCallbacks): EventsFunctionCodeWriter => {
const prefix = makeTimestampedId();
const baseUrl = getBrowserSWPreviewBaseUrl(); const baseUrl = getBrowserSWPreviewBaseUrl();
const extensionsCodeUrl = baseUrl + '/extensions-code';
// At startup, clean up the old generated files for extensions code.
(async () => {
try {
// TODO: maybe don't do it at startup because this could break multiple tabs!
// TODO: Also consider doing a preview per tab?
await deleteFilesWithPrefix(extensionsCodeUrl + '/');
} catch (error) {
console.error(
`[BrowserSWEventsFunctionCodeWriter] Failed to clean generated files for extensions code in "${extensionsCodeUrl}/".`,
error
);
}
})();
const getPathFor = (codeNamespace: string) => { const getPathFor = (codeNamespace: string) => {
return `${baseUrl}/${prefix}/${slugs(codeNamespace)}.js`; return `${extensionsCodeUrl}/${slugs(codeNamespace)}.js`;
}; };
return { return {
@@ -95,9 +110,7 @@ export const makeBrowserSWEventsFunctionCodeWriter = ({
const path = getPathFor(functionCodeNamespace); const path = getPathFor(functionCodeNamespace);
onWriteFile({ includeFile: path, content: code }); onWriteFile({ includeFile: path, content: code });
const relativePath = path.replace(baseUrl, ''); const relativePath = path.replace(baseUrl, '');
console.log(
`[BrowserSWEventsFunctionCodeWriter] Writing function code to ${relativePath}...`
);
return writeFileInNextBatch(relativePath, code); return writeFileInNextBatch(relativePath, code);
}, },
@@ -108,9 +121,7 @@ export const makeBrowserSWEventsFunctionCodeWriter = ({
const path = getPathFor(behaviorCodeNamespace); const path = getPathFor(behaviorCodeNamespace);
onWriteFile({ includeFile: path, content: code }); onWriteFile({ includeFile: path, content: code });
const relativePath = path.replace(baseUrl, ''); const relativePath = path.replace(baseUrl, '');
console.log(
`[BrowserSWEventsFunctionCodeWriter] Writing behavior code to ${path}...`
);
return writeFileInNextBatch(relativePath, code); return writeFileInNextBatch(relativePath, code);
}, },
@@ -121,9 +132,7 @@ export const makeBrowserSWEventsFunctionCodeWriter = ({
const path = getPathFor(objectCodeNamespace); const path = getPathFor(objectCodeNamespace);
onWriteFile({ includeFile: path, content: code }); onWriteFile({ includeFile: path, content: code });
const relativePath = path.replace(baseUrl, ''); const relativePath = path.replace(baseUrl, '');
console.log(
`[BrowserSWEventsFunctionCodeWriter] Writing object code to ${path}...`
);
return writeFileInNextBatch(relativePath, code); return writeFileInNextBatch(relativePath, code);
}, },
}; };

View File

@@ -59,19 +59,21 @@ const setupWindowClosedPolling = () => {
}, 1000); }, 1000);
}; };
const PREVIEWS_ORIGIN = 'https://game-previews.gdevelop.io'; let previewOrigin = null;
/** /**
* A debugger server implemented using the ability to send/receive messages * A debugger server implemented using the ability to send/receive messages
* from popup windows in the browser. * from popup windows in the browser.
*/ */
export const browserPreviewDebuggerServer: PreviewDebuggerServer = { export const browserPreviewDebuggerServer: PreviewDebuggerServer = {
startServer: async () => { startServer: async ({ origin }) => {
if (debuggerServerState === 'started') return; if (debuggerServerState === 'started') return;
debuggerServerState = 'started'; debuggerServerState = 'started';
previewOrigin = origin;
window.addEventListener('message', event => { window.addEventListener('message', event => {
if (event.origin !== PREVIEWS_ORIGIN) return; if (event.origin !== previewOrigin) return;
const id = getDebuggerIdForPreviewWindow(event.source); const id = getDebuggerIdForPreviewWindow(event.source);
if (id === null) return; // Could not find the id of this preview window. if (id === null) return; // Could not find the id of this preview window.
@@ -98,7 +100,7 @@ export const browserPreviewDebuggerServer: PreviewDebuggerServer = {
if (!previewWindow) return; if (!previewWindow) return;
try { try {
previewWindow.postMessage(message, PREVIEWS_ORIGIN); previewWindow.postMessage(message, previewOrigin);
} catch (error) { } catch (error) {
console.error('Unable to send a message to the preview window:', error); console.error('Unable to send a message to the preview window:', error);
} }

View File

@@ -20,6 +20,7 @@ import { displayBlackLoadingScreenOrThrow } from '../../../Utils/BrowserExternal
import { getGDevelopResourceJwtToken } from '../../../Utils/GDevelopServices/Project'; import { getGDevelopResourceJwtToken } from '../../../Utils/GDevelopServices/Project';
import { isNativeMobileApp } from '../../../Utils/Platform'; import { isNativeMobileApp } from '../../../Utils/Platform';
import { getIDEVersionWithHash } from '../../../Version'; import { getIDEVersionWithHash } from '../../../Version';
import { getBrowserSWPreviewBaseUrl } from '../BrowserSWPreviewLauncher/BrowserSWPreviewIndexedDB';
const gd: libGDevelop = global.gd; const gd: libGDevelop = global.gd;
type State = {| type State = {|
@@ -52,6 +53,7 @@ export const immediatelyOpenNewPreviewWindow = (
"Can't open the preview window because of browser restrictions." "Can't open the preview window because of browser restrictions."
); );
} }
console.log('Preview window opened', previewWindow);
displayBlackLoadingScreenOrThrow(previewWindow); displayBlackLoadingScreenOrThrow(previewWindow);
@@ -135,7 +137,9 @@ export default class BrowserS3PreviewLauncher extends React.Component<
}).filter(Boolean); }).filter(Boolean);
try { try {
await this.getPreviewDebuggerServer().startServer(); await this.getPreviewDebuggerServer().startServer({
origin: new URL(getBrowserSWPreviewBaseUrl()).origin,
});
} catch (err) { } catch (err) {
// Ignore any error when running the debugger server - the preview // Ignore any error when running the debugger server - the preview
// can still work without it. // can still work without it.

View File

@@ -1,6 +1,9 @@
// @flow // @flow
import path from 'path-browserify'; import path from 'path-browserify';
import { deleteFilesWithPrefix, putFile } from '../../Utils/BrowserSWIndexedDB'; import {
deleteFilesWithPrefix,
putFile,
} from './BrowserSWPreviewLauncher/BrowserSWPreviewIndexedDB';
const gd: libGDevelop = global.gd; const gd: libGDevelop = global.gd;
export type TextFileDescriptor = {| export type TextFileDescriptor = {|
@@ -10,7 +13,6 @@ export type TextFileDescriptor = {|
type ConstructorArgs = {| type ConstructorArgs = {|
filesContent: Array<TextFileDescriptor>, filesContent: Array<TextFileDescriptor>,
prefix: string,
baseUrl: string, baseUrl: string,
|}; |};
@@ -51,7 +53,6 @@ const getContentType = (filePath: string): string => {
* and serves them via a service worker for GDevelop previews. * and serves them via a service worker for GDevelop previews.
*/ */
export default class BrowserSWFileSystem { export default class BrowserSWFileSystem {
prefix: string;
baseUrl: string; baseUrl: string;
// Store the content of some files. // Store the content of some files.
@@ -64,12 +65,13 @@ export default class BrowserSWFileSystem {
contentType: string, contentType: string,
|}> = []; |}> = [];
_pendingDeleteOperations: Array<Promise<any>> = [];
// Store a set of all external URLs copied so that we can simulate // Store a set of all external URLs copied so that we can simulate
// readDir result. // readDir result.
_allCopiedExternalUrls = new Set<string>(); _allCopiedExternalUrls = new Set<string>();
constructor({ filesContent, prefix, baseUrl }: ConstructorArgs) { constructor({ filesContent, baseUrl }: ConstructorArgs) {
this.prefix = prefix;
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this._indexedFilesContent = {}; this._indexedFilesContent = {};
@@ -84,6 +86,15 @@ export default class BrowserSWFileSystem {
* Uploads all pending files to IndexedDB. * Uploads all pending files to IndexedDB.
*/ */
applyPendingOperations = async () => { applyPendingOperations = async () => {
try {
await Promise.all(this._pendingDeleteOperations);
} catch (error) {
console.error(
'[BrowserSWFileSystem] Error while deleting files in IndexedDB. Ignoring.',
error
);
}
try { try {
console.log( console.log(
`[BrowserSWFileSystem] Storing ${ `[BrowserSWFileSystem] Storing ${
@@ -133,16 +144,7 @@ export default class BrowserSWFileSystem {
clearDir = (path: string) => { clearDir = (path: string) => {
// TODO: add to a pending operation list so we ensure it's executed. // TODO: add to a pending operation list so we ensure it's executed.
console.info(`[BrowserSWFileSystem] Clearing directory: ${path}...`); console.info(`[BrowserSWFileSystem] Clearing directory: ${path}...`);
deleteFilesWithPrefix(path) this._pendingDeleteOperations.push(deleteFilesWithPrefix(path));
.then(() => {
console.info(`[BrowserSWFileSystem] Cleared directory: ${path}`);
})
.catch(error => {
console.error(
`[BrowserSWFileSystem] Error clearing directory: ${path}`,
error
);
});
}; };
getTempDir = () => { getTempDir = () => {

View File

@@ -32,7 +32,7 @@ export const getBrowserSWPreviewBaseUrl = (): string => {
* Opens or returns the existing IndexedDB database connection. * Opens or returns the existing IndexedDB database connection.
* Handles database upgrades and version management. * Handles database upgrades and version management.
*/ */
export const openDatabase = (): Promise<IDBDatabase> => { const openBrowserSWPreviewIndexedDB = (): Promise<IDBDatabase> => {
if (dbInstance && dbInstance.version === DB_VERSION) { if (dbInstance && dbInstance.version === DB_VERSION) {
return Promise.resolve(dbInstance); return Promise.resolve(dbInstance);
} }
@@ -106,10 +106,7 @@ export const openDatabase = (): Promise<IDBDatabase> => {
}; };
/** /**
* Stores a file in IndexedDB. * 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 ( export const putFile = async (
path: string, path: string,
@@ -117,7 +114,7 @@ export const putFile = async (
contentType: string contentType: string
): Promise<void> => { ): Promise<void> => {
try { try {
const db = await openDatabase(); const db = await openBrowserSWPreviewIndexedDB();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
@@ -134,7 +131,6 @@ export const putFile = async (
}; };
transaction.oncomplete = () => { transaction.oncomplete = () => {
console.log('[BrowserSWIndexedDB] File stored successfully:', path);
resolve(); resolve();
}; };
@@ -170,15 +166,15 @@ export const putFile = async (
}; };
/** /**
* Deletes all files with a given path prefix. * Deletes all "files" stored in IndexedDB 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/') * @param pathPrefix - The path prefix to match.
*/ */
export const deleteFilesWithPrefix = async ( export const deleteFilesWithPrefix = async (
pathPrefix: string pathPrefix: string
): Promise<number> => { ): Promise<number> => {
try { try {
const db = await openDatabase(); const db = await openBrowserSWPreviewIndexedDB();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
@@ -196,12 +192,6 @@ export const deleteFilesWithPrefix = async (
}; };
transaction.oncomplete = () => { transaction.oncomplete = () => {
console.log(
'[BrowserSWIndexedDB] Deleted',
deletedCount,
'files with prefix:',
pathPrefix
);
resolve(deletedCount); resolve(deletedCount);
}; };
@@ -246,15 +236,3 @@ export const deleteFilesWithPrefix = async (
throw 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

@@ -8,10 +8,8 @@ import {
type PreviewOptions, type PreviewOptions,
type PreviewLauncherProps, type PreviewLauncherProps,
} from '../../PreviewLauncher.flow'; } from '../../PreviewLauncher.flow';
import { makeTimestampedId } from '../../../Utils/TimestampedId';
import { import {
browserPreviewDebuggerServer, browserPreviewDebuggerServer,
getExistingPreviewWindowForDebuggerId,
registerNewPreviewWindow, registerNewPreviewWindow,
} from '../BrowserS3PreviewLauncher/BrowserPreviewDebuggerServer'; } from '../BrowserS3PreviewLauncher/BrowserPreviewDebuggerServer';
import Window from '../../../Utils/Window'; import Window from '../../../Utils/Window';
@@ -19,7 +17,7 @@ import { displayBlackLoadingScreenOrThrow } from '../../../Utils/BrowserExternal
import { getGDevelopResourceJwtToken } from '../../../Utils/GDevelopServices/Project'; import { getGDevelopResourceJwtToken } from '../../../Utils/GDevelopServices/Project';
import { isNativeMobileApp } from '../../../Utils/Platform'; import { isNativeMobileApp } from '../../../Utils/Platform';
import { getIDEVersionWithHash } from '../../../Version'; import { getIDEVersionWithHash } from '../../../Version';
import { getBrowserSWPreviewBaseUrl } from '../../../Utils/BrowserSWIndexedDB'; import { getBrowserSWPreviewBaseUrl } from './BrowserSWPreviewIndexedDB';
const gd: libGDevelop = global.gd; const gd: libGDevelop = global.gd;
type State = {| type State = {|
@@ -77,9 +75,12 @@ export default class BrowserSWPreviewLauncher extends React.Component<
return findGDJS('preview').then(({ gdjsRoot, filesContent }) => { return findGDJS('preview').then(({ gdjsRoot, filesContent }) => {
console.info('[BrowserSWPreviewLauncher] GDJS found in', gdjsRoot); console.info('[BrowserSWPreviewLauncher] GDJS found in', gdjsRoot);
const prefix = makeTimestampedId(); const isForInGameEdition = false; // TODO: adapt for the 3D editor branch.
const baseUrl = getBrowserSWPreviewBaseUrl(); const baseUrl = getBrowserSWPreviewBaseUrl();
const outputDir = `${baseUrl}/${prefix}`; const outputDir = `${baseUrl}/${
isForInGameEdition ? 'in-game-editor-preview' : 'preview'
}`;
console.log( console.log(
'[BrowserSWPreviewLauncher] Preview will be served from:', '[BrowserSWPreviewLauncher] Preview will be served from:',
@@ -89,7 +90,6 @@ export default class BrowserSWPreviewLauncher extends React.Component<
const browserSWFileSystem = new BrowserSWFileSystem({ const browserSWFileSystem = new BrowserSWFileSystem({
filesContent, filesContent,
baseUrl: `${baseUrl}/`, baseUrl: `${baseUrl}/`,
prefix,
}); });
const fileSystem = assignIn( const fileSystem = assignIn(
new gd.AbstractFileSystemJS(), new gd.AbstractFileSystemJS(),
@@ -113,21 +113,12 @@ export default class BrowserSWPreviewLauncher extends React.Component<
}); });
const debuggerIds = this.getPreviewDebuggerServer().getExistingDebuggerIds(); const debuggerIds = this.getPreviewDebuggerServer().getExistingDebuggerIds();
const lastDebuggerId = debuggerIds.length const shouldHotReload = previewOptions.hotReload && !!debuggerIds.length;
? debuggerIds[debuggerIds.length - 1]
: null;
const shouldHotReload = previewOptions.hotReload && lastDebuggerId !== null;
// We abuse the "hot reload" to choose if we open a new window or replace // Immediately open windows (otherwise Safari will block the window opening if done after
// the content of an existing one. But hot reload is NOT implemented (yet - // an asynchronous operation).
// it would need to generate the preview in the same place and trigger a reload const previewWindows = shouldHotReload
// of the scripts). ? []
const existingPreviewWindow = shouldHotReload
? getExistingPreviewWindowForDebuggerId(lastDebuggerId)
: null;
const previewWindows = existingPreviewWindow
? [existingPreviewWindow]
: Array.from({ length: numberOfWindows }, () => { : Array.from({ length: numberOfWindows }, () => {
try { try {
return immediatelyOpenNewPreviewWindow(project); return immediatelyOpenNewPreviewWindow(project);
@@ -141,7 +132,9 @@ export default class BrowserSWPreviewLauncher extends React.Component<
}).filter(Boolean); }).filter(Boolean);
try { try {
await this.getPreviewDebuggerServer().startServer(); await this.getPreviewDebuggerServer().startServer({
origin: new URL(getBrowserSWPreviewBaseUrl()).origin,
});
} catch (err) { } catch (err) {
// Ignore any error when running the debugger server - the preview // Ignore any error when running the debugger server - the preview
// can still work without it. // can still work without it.
@@ -168,15 +161,18 @@ export default class BrowserSWPreviewLauncher extends React.Component<
previewExportOptions.setExternalLayoutName(externalLayout.getName()); previewExportOptions.setExternalLayoutName(externalLayout.getName());
} }
if (isNativeMobileApp()) { previewExportOptions.useWindowMessageDebuggerClient();
previewExportOptions.useMinimalDebuggerClient();
} else { const includeFileHashs = this.props.getIncludeFileHashs();
previewExportOptions.useWindowMessageDebuggerClient(); for (const includeFile in includeFileHashs) {
const hash = includeFileHashs[includeFile];
previewExportOptions.setIncludeFileHash(includeFile, hash);
} }
// Scripts generated from extensions keep the same URL even after being modified. previewExportOptions.setProjectDataOnlyExport(
// Use a cache bursting parameter to force the browser to reload them. // Only export project data if asked and if a hot-reloading is being done.
previewExportOptions.setNonRuntimeScriptsCacheBurst(Date.now()); shouldHotReload && previewOptions.projectDataOnlyExport
);
previewExportOptions.setFullLoadingScreen( previewExportOptions.setFullLoadingScreen(
previewOptions.fullLoadingScreen previewOptions.fullLoadingScreen
@@ -233,26 +229,32 @@ export default class BrowserSWPreviewLauncher extends React.Component<
previewExportOptions.delete(); previewExportOptions.delete();
exporter.delete(); exporter.delete();
// Store files in IndexedDB instead of uploading to S3
console.log( console.log(
'[BrowserSWPreviewLauncher] Storing preview files in IndexedDB...' '[BrowserSWPreviewLauncher] Storing preview files in IndexedDB...'
); );
await browserSWFileSystem.applyPendingOperations(); await browserSWFileSystem.applyPendingOperations();
// Change the HTML file displayed by the preview window so that it starts loading if (shouldHotReload) {
// the game. console.log('[BrowserSWPreviewLauncher] Triggering hot reload...');
console.log('[BrowserSWPreviewLauncher] Opening preview in window(s)...'); debuggerIds.forEach(debuggerId => {
previewWindows.forEach((previewWindow: WindowProxy) => { this.getPreviewDebuggerServer().sendMessage(debuggerId, {
previewWindow.location = outputDir + '/index.html'; command: 'hotReload',
try { });
previewWindow.focus(); });
} catch (e) {} } else {
}); console.log(
'[BrowserSWPreviewLauncher] Opening new preview window(s)...'
// If the preview windows are new, register them so that they can be accessed );
// by the debugger and for the captures to be detected when they close.
if (!existingPreviewWindow) {
previewWindows.forEach((previewWindow: WindowProxy) => { previewWindows.forEach((previewWindow: WindowProxy) => {
// Change the HTML file displayed by the preview window so that it starts loading
// the game.
previewWindow.location = outputDir + '/index.html';
try {
previewWindow.focus();
} catch (e) {}
// Register the window so that it can be accessed
// by the debugger and for the captures to be detected when it closes.
const debuggerId = registerNewPreviewWindow(previewWindow); const debuggerId = registerNewPreviewWindow(previewWindow);
browserPreviewDebuggerServer.registerCallbacks({ browserPreviewDebuggerServer.registerCallbacks({
onErrorReceived: () => {}, onErrorReceived: () => {},

View File

@@ -204,7 +204,7 @@ export default class LocalPreviewLauncher extends React.Component<
// useful if the user opens the Debugger editor later, or want to // useful if the user opens the Debugger editor later, or want to
// hot reload. // hot reload.
return this.getPreviewDebuggerServer() return this.getPreviewDebuggerServer()
.startServer() .startServer({})
.catch(err => { .catch(err => {
// Ignore any error when running the debugger server - the preview // Ignore any error when running the debugger server - the preview
// can still work without it. // can still work without it.

View File

@@ -91,7 +91,7 @@ export type ServerAddress = {
/** Interface to run a debugger server for previews. */ /** Interface to run a debugger server for previews. */
export type PreviewDebuggerServer = {| export type PreviewDebuggerServer = {|
startServer: () => Promise<void>, startServer: ({ origin?: string }) => Promise<void>,
getServerState: () => 'started' | 'stopped', getServerState: () => 'started' | 'stopped',
getExistingDebuggerIds: () => Array<DebuggerId>, getExistingDebuggerIds: () => Array<DebuggerId>,
sendMessage: (id: DebuggerId, message: Object) => void, sendMessage: (id: DebuggerId, message: Object) => void,

View File

@@ -0,0 +1,60 @@
// @flow
const PUBLIC_URL: string = process.env.PUBLIC_URL || '';
const isDev = process.env.NODE_ENV !== 'production';
export function registerServiceWorker() {
const enabled = true;
const serviceWorker = navigator.serviceWorker;
if (enabled && serviceWorker) {
window.addEventListener('load', () => {
// 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`;
serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
const alreadyHasAServiceWorker = !!serviceWorker.controller;
if (alreadyHasAServiceWorker) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all tabs for this page are closed.'
);
} else {
// Service worker has been installed for the first time.
console.log('Content is cached for offline use.');
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
if (isDev) {
serviceWorker.ready.then(registration => {
// Forces a check right now for a newer service worker script in development.
// If there is one, it will be installed (see the service worker script to verify how in development
// a new service worker script does a `self.skipWaiting()` and `self.clients.claim()`).
registration.update();
});
}
});
} else {
console.log('Service Worker disabled - TODO: fallback to S3?');
}
}

View File

@@ -7,7 +7,7 @@ import {
sendProgramOpening, sendProgramOpening,
installAnalyticsEvents, installAnalyticsEvents,
} from './Utils/Analytics/EventSender'; } from './Utils/Analytics/EventSender';
import { registerServiceWorker } from './serviceWorker'; import { registerServiceWorker } from './ServiceWorkerSetup';
import './UI/icomoon-font.css'; // Styles for Icomoon font. import './UI/icomoon-font.css'; // Styles for Icomoon font.
import optionalRequire from './Utils/OptionalRequire'; import optionalRequire from './Utils/OptionalRequire';
import { loadScript } from './Utils/LoadScript'; import { loadScript } from './Utils/LoadScript';

View File

@@ -1,112 +0,0 @@
// @flow
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
const PUBLIC_URL: string = process.env.PUBLIC_URL || '';
const isDev = process.env.NODE_ENV !== 'production';
export function registerServiceWorker(config) {
const enabled = true;
if (enabled && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
console.log(
`The origin in PUBLIC_URL (${
publicUrl.origin
}) is different than the current origin (${
window.location.origin
}) - Service Worker disabled`
);
return;
}
window.addEventListener('load', () => {
// 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 => {
if (isDev) registration.update(); // TODO
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
}
});
} else {
console.log('Service Worker disabled - TODO: fallback to S3?');
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}