mirror of
https://github.com/4ian/GDevelop.git
synced 2025-10-15 10:19:04 +00:00
[WIP] Make hot reload work in web-app
This commit is contained in:
@@ -70,9 +70,7 @@ async function getPreviewFile(path) {
|
||||
|
||||
request.onsuccess = () => {
|
||||
const result = request.result;
|
||||
if (result) {
|
||||
console.log('[ServiceWorker] File retrieved from IndexedDB:', path, '(' + result.bytes.byteLength + ' bytes)');
|
||||
} else {
|
||||
if (!result) {
|
||||
console.warn('[ServiceWorker] File not found in IndexedDB:', path);
|
||||
}
|
||||
resolve(result || null);
|
||||
@@ -103,7 +101,6 @@ self.addEventListener('fetch', (event) => {
|
||||
// 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 {
|
||||
@@ -121,7 +118,6 @@ self.addEventListener('fetch', (event) => {
|
||||
}
|
||||
|
||||
// Return the file with appropriate headers
|
||||
console.log('[ServiceWorker] Serving file from IndexedDB:', relativePath, 'Content-Type:', fileRecord.contentType);
|
||||
return new Response(fileRecord.bytes, {
|
||||
status: 200,
|
||||
headers: {
|
||||
|
@@ -3,8 +3,7 @@ import {
|
||||
type EventsFunctionCodeWriter,
|
||||
type EventsFunctionCodeWriterCallbacks,
|
||||
} from '..';
|
||||
import { putFile } from '../../Utils/BrowserSWIndexedDB';
|
||||
import { makeTimestampedId } from '../../Utils/TimestampedId';
|
||||
import { deleteFilesWithPrefix, putFile } from '../../Utils/BrowserSWIndexedDB';
|
||||
import slugs from 'slugs';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { getBrowserSWPreviewBaseUrl } from '../../Utils/BrowserSWIndexedDB';
|
||||
@@ -78,11 +77,23 @@ const writeFileInNextBatch = (path: string, content: string) => {
|
||||
export const makeBrowserSWEventsFunctionCodeWriter = ({
|
||||
onWriteFile,
|
||||
}: EventsFunctionCodeWriterCallbacks): EventsFunctionCodeWriter => {
|
||||
const prefix = makeTimestampedId();
|
||||
const baseUrl = getBrowserSWPreviewBaseUrl();
|
||||
const extensionsCodeUrl = baseUrl + '/extensions-code';
|
||||
|
||||
// At startup, clean up the old generated files for extensions code.
|
||||
(async () => {
|
||||
try {
|
||||
await deleteFilesWithPrefix(extensionsCodeUrl + '/');
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[BrowserSWEventsFunctionCodeWriter] Failed to clean generated files for extensions code in "${extensionsCodeUrl}/".`,
|
||||
error
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
const getPathFor = (codeNamespace: string) => {
|
||||
return `${baseUrl}/${prefix}/${slugs(codeNamespace)}.js`;
|
||||
return `${extensionsCodeUrl}/${slugs(codeNamespace)}.js`;
|
||||
};
|
||||
|
||||
return {
|
||||
|
@@ -59,19 +59,21 @@ const setupWindowClosedPolling = () => {
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const PREVIEWS_ORIGIN = 'https://game-previews.gdevelop.io';
|
||||
let previewOrigin = null;
|
||||
|
||||
/**
|
||||
* A debugger server implemented using the ability to send/receive messages
|
||||
* from popup windows in the browser.
|
||||
*/
|
||||
export const browserPreviewDebuggerServer: PreviewDebuggerServer = {
|
||||
startServer: async () => {
|
||||
startServer: async ({ origin }) => {
|
||||
if (debuggerServerState === 'started') return;
|
||||
debuggerServerState = 'started';
|
||||
|
||||
previewOrigin = origin;
|
||||
|
||||
window.addEventListener('message', event => {
|
||||
if (event.origin !== PREVIEWS_ORIGIN) return;
|
||||
if (event.origin !== previewOrigin) return;
|
||||
|
||||
const id = getDebuggerIdForPreviewWindow(event.source);
|
||||
if (id === null) return; // Could not find the id of this preview window.
|
||||
@@ -98,7 +100,7 @@ export const browserPreviewDebuggerServer: PreviewDebuggerServer = {
|
||||
if (!previewWindow) return;
|
||||
|
||||
try {
|
||||
previewWindow.postMessage(message, PREVIEWS_ORIGIN);
|
||||
previewWindow.postMessage(message, previewOrigin);
|
||||
} catch (error) {
|
||||
console.error('Unable to send a message to the preview window:', error);
|
||||
}
|
||||
|
@@ -20,6 +20,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 = {|
|
||||
@@ -52,6 +53,7 @@ export const immediatelyOpenNewPreviewWindow = (
|
||||
"Can't open the preview window because of browser restrictions."
|
||||
);
|
||||
}
|
||||
console.log('Preview window opened', previewWindow);
|
||||
|
||||
displayBlackLoadingScreenOrThrow(previewWindow);
|
||||
|
||||
@@ -135,7 +137,9 @@ export default class BrowserS3PreviewLauncher extends React.Component<
|
||||
}).filter(Boolean);
|
||||
|
||||
try {
|
||||
await this.getPreviewDebuggerServer().startServer();
|
||||
await this.getPreviewDebuggerServer().startServer({
|
||||
origin: new URL(getBrowserSWPreviewBaseUrl()).origin,
|
||||
});
|
||||
} catch (err) {
|
||||
// Ignore any error when running the debugger server - the preview
|
||||
// can still work without it.
|
||||
|
@@ -10,7 +10,6 @@ export type TextFileDescriptor = {|
|
||||
|
||||
type ConstructorArgs = {|
|
||||
filesContent: Array<TextFileDescriptor>,
|
||||
prefix: string,
|
||||
baseUrl: string,
|
||||
|};
|
||||
|
||||
@@ -51,7 +50,6 @@ const getContentType = (filePath: string): string => {
|
||||
* and serves them via a service worker for GDevelop previews.
|
||||
*/
|
||||
export default class BrowserSWFileSystem {
|
||||
prefix: string;
|
||||
baseUrl: string;
|
||||
|
||||
// Store the content of some files.
|
||||
@@ -64,12 +62,13 @@ export default class BrowserSWFileSystem {
|
||||
contentType: string,
|
||||
|}> = [];
|
||||
|
||||
_pendingDeleteOperations: Array<Promise<any>> = [];
|
||||
|
||||
// Store a set of all external URLs copied so that we can simulate
|
||||
// readDir result.
|
||||
_allCopiedExternalUrls = new Set<string>();
|
||||
|
||||
constructor({ filesContent, prefix, baseUrl }: ConstructorArgs) {
|
||||
this.prefix = prefix;
|
||||
constructor({ filesContent, baseUrl }: ConstructorArgs) {
|
||||
this.baseUrl = baseUrl;
|
||||
|
||||
this._indexedFilesContent = {};
|
||||
@@ -84,6 +83,15 @@ export default class BrowserSWFileSystem {
|
||||
* Uploads all pending files to IndexedDB.
|
||||
*/
|
||||
applyPendingOperations = async () => {
|
||||
try {
|
||||
await Promise.all(this._pendingDeleteOperations);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[BrowserSWFileSystem] Error while deleting files in IndexedDB. Ignoring.',
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[BrowserSWFileSystem] Storing ${
|
||||
@@ -133,16 +141,7 @@ export default class BrowserSWFileSystem {
|
||||
clearDir = (path: string) => {
|
||||
// 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
|
||||
);
|
||||
});
|
||||
this._pendingDeleteOperations.push(deleteFilesWithPrefix(path));
|
||||
};
|
||||
|
||||
getTempDir = () => {
|
||||
|
@@ -8,10 +8,8 @@ import {
|
||||
type PreviewOptions,
|
||||
type PreviewLauncherProps,
|
||||
} from '../../PreviewLauncher.flow';
|
||||
import { makeTimestampedId } from '../../../Utils/TimestampedId';
|
||||
import {
|
||||
browserPreviewDebuggerServer,
|
||||
getExistingPreviewWindowForDebuggerId,
|
||||
registerNewPreviewWindow,
|
||||
} from '../BrowserS3PreviewLauncher/BrowserPreviewDebuggerServer';
|
||||
import Window from '../../../Utils/Window';
|
||||
@@ -77,9 +75,10 @@ export default class BrowserSWPreviewLauncher extends React.Component<
|
||||
return findGDJS('preview').then(({ gdjsRoot, filesContent }) => {
|
||||
console.info('[BrowserSWPreviewLauncher] GDJS found in', gdjsRoot);
|
||||
|
||||
const prefix = makeTimestampedId();
|
||||
const isForInGameEdition = false; // TODO: adapt for the 3D editor branch.
|
||||
|
||||
const baseUrl = getBrowserSWPreviewBaseUrl();
|
||||
const outputDir = `${baseUrl}/${prefix}`;
|
||||
const outputDir = `${baseUrl}/${isForInGameEdition ? 'in-game-editor-preview' : 'preview'}`;
|
||||
|
||||
console.log(
|
||||
'[BrowserSWPreviewLauncher] Preview will be served from:',
|
||||
@@ -89,7 +88,6 @@ export default class BrowserSWPreviewLauncher extends React.Component<
|
||||
const browserSWFileSystem = new BrowserSWFileSystem({
|
||||
filesContent,
|
||||
baseUrl: `${baseUrl}/`,
|
||||
prefix,
|
||||
});
|
||||
const fileSystem = assignIn(
|
||||
new gd.AbstractFileSystemJS(),
|
||||
@@ -113,21 +111,12 @@ export default class BrowserSWPreviewLauncher extends React.Component<
|
||||
});
|
||||
|
||||
const debuggerIds = this.getPreviewDebuggerServer().getExistingDebuggerIds();
|
||||
const lastDebuggerId = debuggerIds.length
|
||||
? debuggerIds[debuggerIds.length - 1]
|
||||
: null;
|
||||
const shouldHotReload = previewOptions.hotReload && lastDebuggerId !== null;
|
||||
const shouldHotReload = previewOptions.hotReload && !!debuggerIds.length;
|
||||
|
||||
// We abuse the "hot reload" to choose if we open a new window or replace
|
||||
// the content of an existing one. But hot reload is NOT implemented (yet -
|
||||
// it would need to generate the preview in the same place and trigger a reload
|
||||
// of the scripts).
|
||||
const existingPreviewWindow = shouldHotReload
|
||||
? getExistingPreviewWindowForDebuggerId(lastDebuggerId)
|
||||
: null;
|
||||
|
||||
const previewWindows = existingPreviewWindow
|
||||
? [existingPreviewWindow]
|
||||
// Immediatel open windows (otherwise Safari will block the window opening if done after
|
||||
// an asynchronous operation).
|
||||
const previewWindows = shouldHotReload
|
||||
? []
|
||||
: Array.from({ length: numberOfWindows }, () => {
|
||||
try {
|
||||
return immediatelyOpenNewPreviewWindow(project);
|
||||
@@ -141,7 +130,9 @@ export default class BrowserSWPreviewLauncher extends React.Component<
|
||||
}).filter(Boolean);
|
||||
|
||||
try {
|
||||
await this.getPreviewDebuggerServer().startServer();
|
||||
await this.getPreviewDebuggerServer().startServer({
|
||||
origin: new URL(getBrowserSWPreviewBaseUrl()).origin,
|
||||
});
|
||||
} catch (err) {
|
||||
// Ignore any error when running the debugger server - the preview
|
||||
// can still work without it.
|
||||
@@ -168,15 +159,29 @@ export default class BrowserSWPreviewLauncher extends React.Component<
|
||||
previewExportOptions.setExternalLayoutName(externalLayout.getName());
|
||||
}
|
||||
|
||||
if (isNativeMobileApp()) {
|
||||
previewExportOptions.useMinimalDebuggerClient();
|
||||
} else {
|
||||
// TODO
|
||||
// if (isNativeMobileApp()) {
|
||||
// previewExportOptions.useMinimalDebuggerClient();
|
||||
// } else {
|
||||
previewExportOptions.useWindowMessageDebuggerClient();
|
||||
// }
|
||||
|
||||
|
||||
const includeFileHashs = this.props.getIncludeFileHashs();
|
||||
for (const includeFile in includeFileHashs) {
|
||||
const hash = includeFileHashs[includeFile];
|
||||
previewExportOptions.setIncludeFileHash(includeFile, hash);
|
||||
}
|
||||
|
||||
previewExportOptions.setProjectDataOnlyExport(
|
||||
// Only export project data if asked and if a hot-reloading is being done.
|
||||
shouldHotReload && previewOptions.projectDataOnlyExport
|
||||
);
|
||||
|
||||
// TODO: remove as useless now because no cache by the service worker.
|
||||
// Scripts generated from extensions keep the same URL even after being modified.
|
||||
// Use a cache bursting parameter to force the browser to reload them.
|
||||
previewExportOptions.setNonRuntimeScriptsCacheBurst(Date.now());
|
||||
// previewExportOptions.setNonRuntimeScriptsCacheBurst(Date.now());
|
||||
|
||||
previewExportOptions.setFullLoadingScreen(
|
||||
previewOptions.fullLoadingScreen
|
||||
@@ -251,7 +256,15 @@ export default class BrowserSWPreviewLauncher extends React.Component<
|
||||
|
||||
// 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) {
|
||||
if (shouldHotReload) {
|
||||
console.log('Sending hot reload message to debuggers', debuggerIds);
|
||||
debuggerIds.forEach(debuggerId => {
|
||||
console.log('Sending hot reload message to debugger', debuggerId);
|
||||
this.getPreviewDebuggerServer().sendMessage(debuggerId, {
|
||||
command: 'hotReload',
|
||||
});
|
||||
});
|
||||
} else {
|
||||
previewWindows.forEach((previewWindow: WindowProxy) => {
|
||||
const debuggerId = registerNewPreviewWindow(previewWindow);
|
||||
browserPreviewDebuggerServer.registerCallbacks({
|
||||
|
@@ -91,7 +91,7 @@ export type ServerAddress = {
|
||||
|
||||
/** Interface to run a debugger server for previews. */
|
||||
export type PreviewDebuggerServer = {|
|
||||
startServer: () => Promise<void>,
|
||||
startServer: ({ origin?: string }) => Promise<void>,
|
||||
getServerState: () => 'started' | 'stopped',
|
||||
getExistingDebuggerIds: () => Array<DebuggerId>,
|
||||
sendMessage: (id: DebuggerId, message: Object) => void,
|
||||
|
@@ -32,7 +32,7 @@ export const getBrowserSWPreviewBaseUrl = (): string => {
|
||||
* Opens or returns the existing IndexedDB database connection.
|
||||
* Handles database upgrades and version management.
|
||||
*/
|
||||
export const openDatabase = (): Promise<IDBDatabase> => {
|
||||
const openBrowserSWIndexedDB = (): Promise<IDBDatabase> => {
|
||||
if (dbInstance && dbInstance.version === DB_VERSION) {
|
||||
return Promise.resolve(dbInstance);
|
||||
}
|
||||
@@ -106,10 +106,7 @@ export const openDatabase = (): Promise<IDBDatabase> => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Stores a "file" in IndexedDB.
|
||||
*/
|
||||
export const putFile = async (
|
||||
path: string,
|
||||
@@ -117,7 +114,7 @@ export const putFile = async (
|
||||
contentType: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const db = await openDatabase();
|
||||
const db = await openBrowserSWIndexedDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
@@ -134,7 +131,6 @@ export const putFile = async (
|
||||
};
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
console.log('[BrowserSWIndexedDB] File stored successfully:', path);
|
||||
resolve();
|
||||
};
|
||||
|
||||
@@ -170,15 +166,15 @@ export const putFile = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* 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/')
|
||||
* Deletes all "files" stored in IndexedDB with a given path prefix.
|
||||
*
|
||||
* @param pathPrefix - The path prefix to match.
|
||||
*/
|
||||
export const deleteFilesWithPrefix = async (
|
||||
pathPrefix: string
|
||||
): Promise<number> => {
|
||||
try {
|
||||
const db = await openDatabase();
|
||||
const db = await openBrowserSWIndexedDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
@@ -196,12 +192,6 @@ export const deleteFilesWithPrefix = async (
|
||||
};
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
console.log(
|
||||
'[BrowserSWIndexedDB] Deleted',
|
||||
deletedCount,
|
||||
'files with prefix:',
|
||||
pathPrefix
|
||||
);
|
||||
resolve(deletedCount);
|
||||
};
|
||||
|
||||
@@ -246,15 +236,3 @@ export const deleteFilesWithPrefix = async (
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
Reference in New Issue
Block a user