Compare commits

...

8 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
Florian Rival
96ca91e35e Merge branch 'master' into cursor/implement-local-service-worker-file-serving-a93b 2025-10-14 18:55:48 +02:00
Florian Rival
d41998ec1b Update posthog to latest version 2025-10-14 14:28:25 +02:00
Florian Rival
f917d1597e Fix leaderboard entry rate limiting (#7906)
- This enforce slimits of 12 successful saves per minute globally and 6 per minute per
leaderboard. This is enough for the very large majority of games and avoid issues when sending too many scores because of a mistake or wrong game design.
2025-10-14 12:28:09 +02:00
Florian Rival
b6cad244fc Rework SW 2025-10-13 13:01:45 +02:00
Florian Rival
90cadac9d8 [WIP] Working version 2025-10-12 15:56:46 +02:00
Cursor Agent
03cd24d723 feat: Implement local preview using service worker and IndexedDB
Co-authored-by: florian <florian@gdevelop.io>
2025-10-12 15:56:46 +02:00
19 changed files with 1417 additions and 246 deletions

View File

@@ -33,6 +33,49 @@ namespace gdjs {
claimSecret?: string;
};
// Rolling window rate limiting
// Implements rate limiting to prevent abuse:
// - Maximum 12 successful successful entries per minute across all leaderboards
// - Maximum 6 successful successful entries per minute per individual leaderboard
// - Works in addition to existing 500ms cooldown between entry tentatives
let _successfulEntriesGlobal: number[] = []; // Timestamps of successful entries across all leaderboards
const GLOBAL_RATE_LIMIT_COUNT = 12;
const PER_LEADERBOARD_RATE_LIMIT_COUNT = 6;
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute in milliseconds
/**
* Clean old entries from the rolling window (older than 1 minute)
*/
const cleanOldEntries = (
entries: number[],
currentTime: number
): number[] => {
return entries.filter(
(timestamp) => currentTime - timestamp < RATE_LIMIT_WINDOW_MS
);
};
/**
* Check if adding a new entry would exceed global rate limits.
*/
const wouldExceedGlobalSuccessRateLimit = (): boolean => {
const currentTime = Date.now();
_successfulEntriesGlobal = cleanOldEntries(
_successfulEntriesGlobal,
currentTime
);
return _successfulEntriesGlobal.length >= GLOBAL_RATE_LIMIT_COUNT;
};
/**
* Record a successful entry for global rate limiting tracking.
*/
const recordGlobalSuccessfulEntry = (): void => {
const currentTime = Date.now();
_successfulEntriesGlobal.push(currentTime);
};
/**
* Hold the state of the save of a score for a leaderboard.
*/
@@ -43,6 +86,9 @@ namespace gdjs {
/** The promise that will be resolved when the score saving is done (successfully or not). */
lastSavingPromise: Promise<void> | null = null;
/** Timestamps of successful entries for this leaderboard (for rate limiting) */
private _successfulEntries: number[] = [];
// Score that is being saved:
private _currentlySavingScore: number | null = null;
private _currentlySavingPlayerName: string | null = null;
@@ -107,13 +153,36 @@ namespace gdjs {
);
}
private _isTooSoonToSaveAnotherScore(): boolean {
private _wouldExceedPerLeaderboardTentativeRateLimit(): boolean {
// Prevent entries within 500ms of each other (per leaderboard)
// as this would indicate surely a score saved every frame.
//
// Note that is on lastScoreSavingStartedAt, not lastScoreSavingSucceededAt,
// which means we limit tentatives here (and not successes).
return (
!!this.lastScoreSavingStartedAt &&
Date.now() - this.lastScoreSavingStartedAt < 500
);
}
private _wouldExceedPerLeaderboardSuccessRateLimit(): boolean {
const currentTime = Date.now();
this._successfulEntries = cleanOldEntries(
this._successfulEntries,
currentTime
);
return (
this._successfulEntries.length >= PER_LEADERBOARD_RATE_LIMIT_COUNT
);
}
private _recordPerLeaderboardAndGlobalSuccessfulEntry(): void {
const currentTime = Date.now();
this._successfulEntries.push(currentTime);
recordGlobalSuccessfulEntry();
}
startSaving({
playerName,
playerId,
@@ -141,7 +210,7 @@ namespace gdjs {
throw new Error('Ignoring this saving request.');
}
if (this._isTooSoonToSaveAnotherScore()) {
if (this._wouldExceedPerLeaderboardTentativeRateLimit()) {
logger.warn(
'Last entry was sent too little time ago. Ignoring this one.'
);
@@ -154,6 +223,24 @@ namespace gdjs {
throw new Error('Ignoring this saving request.');
}
// Rolling window rate limiting check for successful entries.
if (wouldExceedGlobalSuccessRateLimit()) {
logger.warn(
'Rate limit exceeded. Too many entries have been successfully sent recently across all leaderboards. Ignoring this one.'
);
this._setError('GLOBAL_RATE_LIMIT_EXCEEDED');
throw new Error('Ignoring this saving request.');
}
if (this._wouldExceedPerLeaderboardSuccessRateLimit()) {
logger.warn(
'Rate limit exceeded. Too many entries have been successfully sent recently for this leaderboard. Ignoring this one.'
);
this._setError('LEADERBOARD_RATE_LIMIT_EXCEEDED');
throw new Error('Ignoring this saving request.');
}
let resolveSavingPromise: () => void;
const savingPromise = new Promise<void>((resolve) => {
resolveSavingPromise = resolve;
@@ -169,6 +256,9 @@ namespace gdjs {
return {
closeSaving: (leaderboardEntry) => {
// Record successful entry for rolling window rate limiting.
this._recordPerLeaderboardAndGlobalSuccessfulEntry();
if (savingPromise !== this.lastSavingPromise) {
logger.info(
'Score saving result received, but another save was launched in the meantime - ignoring the result of this one.'
@@ -396,7 +486,10 @@ namespace gdjs {
try {
const { closeSaving, closeSavingWithError } =
scoreSavingState.startSaving({ playerName, score });
scoreSavingState.startSaving({
playerName,
score,
});
try {
const leaderboardEntry = await saveScore({
@@ -440,7 +533,10 @@ namespace gdjs {
try {
const { closeSaving, closeSavingWithError } =
scoreSavingState.startSaving({ playerId, score });
scoreSavingState.startSaving({
playerId,
score,
});
try {
const leaderboardEntryId = await saveScore({

View File

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

View File

@@ -33,7 +33,7 @@
"path-browserify": "^1.0.1",
"pixi-spine": "4.0.4",
"pixi.js-legacy": "7.4.2",
"posthog-js": "^1.57.2",
"posthog-js": "1.275.2",
"prop-types": "^15.5.10",
"qr-creator": "^1.0.0",
"react": "16.14.0",
@@ -5770,6 +5770,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@posthog/core": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.3.0.tgz",
"integrity": "sha512-hxLL8kZNHH098geedcxCz8y6xojkNYbmJEW+1vFXsmPcExyCXIUUJ/34X6xa9GcprKxd0Wsx3vfJQLQX4iVPhw=="
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"license": "BSD-3-Clause"
@@ -25646,16 +25651,43 @@
}
},
"node_modules/posthog-js": {
"version": "1.57.2",
"license": "MIT",
"version": "1.275.2",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.275.2.tgz",
"integrity": "sha512-g1fnV/GAcEdwwk4EVbJ1HMZhlhgKYxG1Z5KPGvr+q5re0ltyVq8jFA2PsF333jvOlI8R01LLdpYSIgU8sBiZfg==",
"dependencies": {
"fflate": "^0.4.1",
"rrweb-snapshot": "^1.1.14"
"@posthog/core": "1.3.0",
"core-js": "^3.38.1",
"fflate": "^0.4.8",
"preact": "^10.19.3",
"web-vitals": "^4.2.4"
},
"peerDependencies": {
"@rrweb/types": "2.0.0-alpha.17",
"rrweb-snapshot": "2.0.0-alpha.17"
},
"peerDependenciesMeta": {
"@rrweb/types": {
"optional": true
},
"rrweb-snapshot": {
"optional": true
}
}
},
"node_modules/posthog-js/node_modules/core-js": {
"version": "3.46.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz",
"integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==",
"hasInstallScript": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/preact": {
"version": "10.13.1",
"license": "MIT",
"version": "10.27.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -30091,10 +30123,6 @@
"node": ">= 10.13.0"
}
},
"node_modules/rrweb-snapshot": {
"version": "1.1.14",
"license": "MIT"
},
"node_modules/rtl-css-js": {
"version": "1.14.0",
"license": "MIT",
@@ -33106,6 +33134,11 @@
"defaults": "^1.0.3"
}
},
"node_modules/web-vitals": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="
},
"node_modules/webidl-conversions": {
"version": "6.1.0",
"dev": true,

View File

@@ -69,7 +69,7 @@
"path-browserify": "^1.0.1",
"pixi-spine": "4.0.4",
"pixi.js-legacy": "7.4.2",
"posthog-js": "^1.57.2",
"posthog-js": "1.275.2",
"prop-types": "^15.5.10",
"qr-creator": "^1.0.0",
"react": "16.14.0",

View File

@@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path');
const shell = require('shelljs');
const workboxBuild = require('workbox-build');
const buildPath = '../build';
const buildPath = '../public';
const VersionMetadata = require('../src/Version/VersionMetadata');
/**
@@ -46,7 +46,7 @@ const buildSW = () => {
return workboxBuild
.injectManifest({
swSrc: 'service-worker-template/service-worker-template.js',
swDest: '../build/service-worker.js',
swDest: '../public/service-worker.js',
globDirectory: buildPath,
globPatterns: [
// Application:
@@ -85,13 +85,13 @@ const buildSW = () => {
.then(({ count, size, warnings }) => {
if (
!replaceInFile(
'../build/service-worker.js',
'../public/service-worker.js',
'VersionMetadata = {}',
'VersionMetadata = ' + JSON.stringify(VersionMetadata)
)
) {
console.error(
'Error while trying to replace version metadata in build/service-worker.js.'
'Error while trying to replace version metadata in public/service-worker.js.'
);
shell.exit(1);
}

View File

@@ -1,64 +1,249 @@
if (typeof importScripts === 'function') {
importScripts(
'https://storage.googleapis.com/workbox-cdn/releases/3.5.0/workbox-sw.js'
);
/* global workbox */
if (workbox) {
// Will be replaced by make-service-worker.js to include the proper version.
const VersionMetadata = {};
/* eslint-disable no-restricted-globals */
// 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.
},
]);
// ============================================================================
// Browser Service Worker powered Preview ("Browser SW Preview"), using IndexedDB.
// ============================================================================
/* injection point for manifest files. */
workbox.precaching.precacheAndRoute([]);
console.log('[ServiceWorker] Service worker file executed');
/* custom cache rules*/
workbox.routing.registerNavigationRoute('/index.html', {
blacklist: [/^\/_/, /\/[^\/]+\.[^\/]+$/],
});
const swURL = new URL(self.location.href);
const isDev = swURL.searchParams.has('dev');
// 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,
}),
],
})
);
// If updated, also update the BrowserSWIndexedDB module.
const DB_NAME = 'gdevelop-browser-sw-preview';
const STORE_NAME = 'files';
const DB_VERSION = 1;
// 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('Workbox could not be loaded - no offline support');
}
} else {
console.log(
'importScripts does not exist on this browser - no offline support'
);
/**
* Opens the IndexedDB database for browser SW preview files.
*/
function openBrowserSWPreviewDB() {
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('[ServiceWorker] Error opening preview database:', error);
reject(error);
};
request.onsuccess = () => {
const db = request.result;
console.log('[ServiceWorker] Preview database opened successfully');
resolve(db);
};
request.onupgradeneeded = () => {
console.log('[ServiceWorker] Upgrading preview database schema...');
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
console.log('[ServiceWorker] Created object store:', STORE_NAME);
}
};
} catch (error) {
console.error('[ServiceWorker] Exception while opening preview database:', error);
reject(error);
}
});
}
/**
* Retrieves a preview file from IndexedDB.
*/
async function getBrowserSWPreviewFile(path) {
try {
const db = await openBrowserSWPreviewDB();
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 {
// 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);
}
const tx = db.transaction(STORE_NAME, 'readonly');
// 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);
safeReject(error);
};
const store = tx.objectStore(STORE_NAME);
const req = store.get(path);
req.onsuccess = () => {
const result = req.result;
safeResolve(result || null);
};
req.onerror = () => {
const error = req.error || new Error('Get operation failed');
console.error('[ServiceWorker] Error retrieving file from IndexedDB:', path, error);
safeReject(error);
};
} catch (error) {
console.error('[ServiceWorker] Exception during get operation:', path, error);
safeReject(error);
}
});
} catch (error) {
console.error('[ServiceWorker] Failed to get file from IndexedDB:', path, error);
throw error;
}
}
/**
* 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 browser SW preview file
if (url.pathname.startsWith('/browser_sw_preview/')) {
const relativePath = url.pathname.replace('/browser_sw_preview', '');
event.respondWith((async () => {
try {
// Try to get the file from IndexedDB
const fileRecord = await getBrowserSWPreviewFile(relativePath);
if (!fileRecord) {
console.warn('[ServiceWorker] File not found in IndexedDB:', relativePath);
return new Response('File not found in browser SW preview storage', {
status: 404,
headers: {
'Content-Type': 'text/plain',
}
});
}
// Return the file with appropriate headers
return new Response(fileRecord.bytes, {
status: 200,
headers: {
'Content-Type': fileRecord.contentType || 'application/octet-stream',
// Prevent caching to ensure latest version is always served
'Cache-Control': 'no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
// CORS headers for cross-origin requests if needed
'Access-Control-Allow-Origin': '*',
}
});
} catch (error) {
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',
}
});
}
})());
// Return early to prevent falling through to workbox routes
return;
}
});
self.addEventListener('install', (event) => {
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...');
if (isDev) {
event.waitUntil(self.clients.claim());
}
});
// ============================================================================
// Standard Workbox Configuration (for "semi-offline"/caching of GDevelop static files and resources)
// ============================================================================
self.__WB_DISABLE_DEV_LOGS = true;
// 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,
}),
],
})
);
// 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');
}

View File

@@ -9,7 +9,7 @@ import './UI/icomoon-font.css'; // Styles for Icomoon font.
// Import for browser only IDE
import browserResourceSources from './ResourcesList/BrowserResourceSources';
import browserResourceExternalEditors from './ResourcesList/BrowserResourceExternalEditors';
import BrowserS3PreviewLauncher from './ExportAndShare/BrowserExporters/BrowserS3PreviewLauncher';
import BrowserSWPreviewLauncher from './ExportAndShare/BrowserExporters/BrowserSWPreviewLauncher';
import {
browserAutomatedExporters,
browserManualExporters,
@@ -18,7 +18,7 @@ import {
import makeExtensionsLoader from './JsExtensionsLoader/BrowserJsExtensionsLoader';
import ObjectsEditorService from './ObjectEditor/ObjectsEditorService';
import ObjectsRenderingService from './ObjectsRendering/ObjectsRenderingService';
import { makeBrowserS3EventsFunctionCodeWriter } from './EventsFunctionsExtensionsLoader/CodeWriters/BrowserS3EventsFunctionCodeWriter';
import { makeBrowserSWEventsFunctionCodeWriter } from './EventsFunctionsExtensionsLoader/CodeWriters/BrowserSWEventsFunctionCodeWriter';
import Providers from './MainFrame/Providers';
import ProjectStorageProviders from './ProjectsStorage/ProjectStorageProviders';
import UrlStorageProvider from './ProjectsStorage/UrlStorageProvider';
@@ -43,7 +43,7 @@ export const create = (authentication: Authentication) => {
<Providers
authentication={authentication}
disableCheckForUpdates={!!appArguments['disable-update-check']}
makeEventsFunctionCodeWriter={makeBrowserS3EventsFunctionCodeWriter}
makeEventsFunctionCodeWriter={makeBrowserSWEventsFunctionCodeWriter}
eventsFunctionsExtensionWriter={BrowserEventsFunctionsExtensionWriter}
eventsFunctionsExtensionOpener={BrowserEventsFunctionsExtensionOpener}
>
@@ -68,7 +68,7 @@ export const create = (authentication: Authentication) => {
<MainFrame
i18n={i18n}
renderPreviewLauncher={(props, ref) => (
<BrowserS3PreviewLauncher {...props} ref={ref} />
<BrowserSWPreviewLauncher {...props} ref={ref} />
)}
renderShareDialog={props => (
<ShareDialog

View File

@@ -0,0 +1,139 @@
// @flow
import {
type EventsFunctionCodeWriter,
type EventsFunctionCodeWriterCallbacks,
} from '..';
import {
deleteFilesWithPrefix,
putFile,
getBrowserSWPreviewBaseUrl,
} from '../../ExportAndShare/BrowserExporters/BrowserSWPreviewLauncher/BrowserSWPreviewIndexedDB';
import slugs from 'slugs';
import debounce from 'lodash/debounce';
let batchedWrites: Array<{
path: string,
content: string,
onSuccess: () => void,
onError: (error: Error) => void,
}> = [];
const flushBatchedWrites = debounce(async () => {
const writes = [...batchedWrites];
console.info(
`[BrowserSWEventsFunctionCodeWriter] Storing a batch of ${
writes.length
} extension generated files in IndexedDB...`,
writes.map(w => w.path)
);
batchedWrites = [];
// Write all files to IndexedDB in parallel
const results = await Promise.allSettled(
writes.map(async write => {
const encoder = new TextEncoder();
const bytes = encoder.encode(write.content).buffer;
await putFile(write.path, bytes, 'text/javascript; charset=utf-8');
return write;
})
);
// Process results and call callbacks
results.forEach((result, index) => {
const write = writes[index];
if (result.status === 'fulfilled') {
write.onSuccess();
} else {
console.error(
`[BrowserSWEventsFunctionCodeWriter] Failed to store: ${write.path}`,
result.reason
);
write.onError(result.reason);
}
});
}, 10); // Wait for up to 10ms, to avoid adding more latency to extension generation.
/**
* Write a file by batching it with other files that are being written.
*
* Extension generated files are written in batches to avoid making a *lot* of IndexedDB transactions
* (games can have from dozens to **hundreds** of extensions and generated files).
*/
const writeFileInNextBatch = (path: string, content: string) => {
return new Promise((resolve, reject) => {
batchedWrites.push({
path,
content,
onSuccess: resolve,
onError: reject,
});
flushBatchedWrites();
});
};
/**
* Create the EventsFunctionCodeWriter that writes generated code for events functions
* to IndexedDB for service worker serving.
*/
export const makeBrowserSWEventsFunctionCodeWriter = ({
onWriteFile,
}: EventsFunctionCodeWriterCallbacks): EventsFunctionCodeWriter => {
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) => {
return `${extensionsCodeUrl}/${slugs(codeNamespace)}.js`;
};
return {
getIncludeFileFor: (codeNamespace: string) => getPathFor(codeNamespace),
writeFunctionCode: (
functionCodeNamespace: string,
code: string
): Promise<void> => {
const path = getPathFor(functionCodeNamespace);
onWriteFile({ includeFile: path, content: code });
const relativePath = path.replace(baseUrl, '');
return writeFileInNextBatch(relativePath, code);
},
writeBehaviorCode: (
behaviorCodeNamespace: string,
code: string
): Promise<void> => {
const path = getPathFor(behaviorCodeNamespace);
onWriteFile({ includeFile: path, content: code });
const relativePath = path.replace(baseUrl, '');
return writeFileInNextBatch(relativePath, code);
},
writeObjectCode: (
objectCodeNamespace: string,
code: string
): Promise<void> => {
const path = getPathFor(objectCodeNamespace);
onWriteFile({ includeFile: path, content: code });
const relativePath = path.replace(baseUrl, '');
return writeFileInNextBatch(relativePath, code);
},
};
};

View File

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

View File

@@ -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);
}

View File

@@ -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 '../BrowserSWPreviewLauncher/BrowserSWPreviewIndexedDB';
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.

View File

@@ -0,0 +1,254 @@
// @flow
import path from 'path-browserify';
import {
deleteFilesWithPrefix,
putFile,
} from './BrowserSWPreviewLauncher/BrowserSWPreviewIndexedDB';
const gd: libGDevelop = global.gd;
export type TextFileDescriptor = {|
filePath: string,
text: string,
|};
type ConstructorArgs = {|
filesContent: Array<TextFileDescriptor>,
baseUrl: string,
|};
const isURL = (filename: string) => {
return (
filename.startsWith('http://') ||
filename.startsWith('https://') ||
filename.startsWith('ftp://') ||
filename.startsWith('blob:') ||
filename.startsWith('data:')
);
};
/**
* Determines the content type based on file extension.
*/
const getContentType = (filePath: string): string => {
const ext = path.extname(filePath).toLowerCase();
const mimeTypes = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.wasm': 'application/wasm',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8',
};
return mimeTypes[ext] || 'application/octet-stream';
};
/**
* An in-memory "file system" that stores files in IndexedDB
* and serves them via a service worker for GDevelop previews.
*/
export default class BrowserSWFileSystem {
baseUrl: string;
// Store the content of some files.
_indexedFilesContent: { [string]: TextFileDescriptor };
// Store all the files that should be written to IndexedDB.
_pendingFiles: Array<{|
path: string,
content: string,
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, baseUrl }: ConstructorArgs) {
this.baseUrl = baseUrl;
this._indexedFilesContent = {};
filesContent.forEach(textFileDescriptor => {
this._indexedFilesContent[
textFileDescriptor.filePath
] = textFileDescriptor;
});
}
/**
* 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 ${
this._pendingFiles.length
} files in IndexedDB for preview...`
);
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})`
);
await putFile(fullPath, bytes, file.contentType);
});
await Promise.all(uploadPromises);
console.log(
`[BrowserSWFileSystem] Successfully stored all ${
this._pendingFiles.length
} preview files in IndexedDB.`
);
} catch (error) {
console.error(
"[BrowserSWFileSystem] Can't store all files in IndexedDB:",
error
);
throw error;
}
};
mkDir = (path: string) => {
// Assume required directories always exist in a virtual file system.
};
dirExists = (path: string) => {
// Assume required directories always exist.
return true;
};
clearDir = (path: string) => {
// TODO: add to a pending operation list so we ensure it's executed.
console.info(`[BrowserSWFileSystem] Clearing directory: ${path}...`);
this._pendingDeleteOperations.push(deleteFilesWithPrefix(path));
};
getTempDir = () => {
return '/virtual-unused-tmp-dir';
};
fileNameFrom = (fullpath: string) => {
if (isURL(fullpath)) return fullpath;
return path.basename(fullpath);
};
dirNameFrom = (fullpath: string) => {
if (isURL(fullpath)) return '';
return path.dirname(fullpath);
};
makeAbsolute = (filename: string, baseDirectory: string) => {
if (isURL(filename)) return filename;
if (!this.isAbsolute(baseDirectory))
baseDirectory = path.resolve(baseDirectory);
return path.resolve(baseDirectory, path.normalize(filename));
};
makeRelative = (filename: string, baseDirectory: string) => {
if (isURL(filename)) return filename;
return path.relative(baseDirectory, path.normalize(filename));
};
isAbsolute = (fullpath: string) => {
if (isURL(fullpath)) return true;
if (fullpath.length === 0) return true;
return (
(fullpath.length > 0 && fullpath.charAt(0) === '/') ||
(fullpath.length > 1 && fullpath.charAt(1) === ':')
);
};
copyFile = (source: string, dest: string) => {
// URLs are not copied, just tracked.
if (isURL(source)) {
this._allCopiedExternalUrls.add(source);
return true;
}
console.warn(
'[BrowserSWFileSystem] Copy not done from',
source,
'to',
dest
);
return true;
};
writeToFile = (fullPath: string, contents: string) => {
// Remove the base URL to get the relative path
const relativePath = fullPath.replace(this.baseUrl, '');
const contentType = getContentType(fullPath);
console.log(
`[BrowserSWFileSystem] Queuing file for IndexedDB: ${relativePath} (${contentType})`
);
// Queue the file to be written to IndexedDB
this._pendingFiles.push({
path: relativePath,
content: contents,
contentType,
});
return true;
};
readFile = (file: string) => {
if (!!this._indexedFilesContent[file])
return this._indexedFilesContent[file].text;
console.error(
`[BrowserSWFileSystem] Unknown file ${file}, returning an empty string`
);
return '';
};
readDir = (path: string, ext: string) => {
ext = ext.toUpperCase();
var output = new gd.VectorString();
// Simulate ReadDir by returning all external URLs
// with the filename matching the extension.
this._allCopiedExternalUrls.forEach(url => {
const upperCaseUrl = url.toUpperCase();
if (upperCaseUrl.indexOf(ext) === upperCaseUrl.length - ext.length) {
output.push_back(url);
}
});
return output;
};
fileExists = (filename: string) => {
if (isURL(filename)) return true;
// Assume all files asked for exist.
return true;
};
}

View File

@@ -0,0 +1,238 @@
// @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.
*/
const openBrowserSWPreviewIndexedDB = (): 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.
*/
export const putFile = async (
path: string,
bytes: ArrayBuffer,
contentType: string
): Promise<void> => {
try {
const db = await openBrowserSWPreviewIndexedDB();
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 = () => {
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" 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 openBrowserSWPreviewIndexedDB();
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 = () => {
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;
}
};

View File

@@ -0,0 +1,314 @@
// @flow
import * as React from 'react';
import BrowserPreviewErrorDialog from '../BrowserS3PreviewLauncher/BrowserPreviewErrorDialog';
import BrowserSWFileSystem from '../BrowserSWFileSystem';
import { findGDJS } from '../../../GameEngineFinder/BrowserS3GDJSFinder';
import assignIn from 'lodash/assignIn';
import {
type PreviewOptions,
type PreviewLauncherProps,
} from '../../PreviewLauncher.flow';
import {
browserPreviewDebuggerServer,
registerNewPreviewWindow,
} from '../BrowserS3PreviewLauncher/BrowserPreviewDebuggerServer';
import Window from '../../../Utils/Window';
import { displayBlackLoadingScreenOrThrow } from '../../../Utils/BrowserExternalWindowUtils';
import { getGDevelopResourceJwtToken } from '../../../Utils/GDevelopServices/Project';
import { isNativeMobileApp } from '../../../Utils/Platform';
import { getIDEVersionWithHash } from '../../../Version';
import { getBrowserSWPreviewBaseUrl } from './BrowserSWPreviewIndexedDB';
const gd: libGDevelop = global.gd;
type State = {|
error: ?Error,
|};
let nextPreviewWindowId = 0;
/**
* Open a window showing a black "loading..." screen. It's important this is done
* NOT in an asynchronous way but JUST after a click. Otherwise, browsers like Safari
* will block the window opening.
*/
export const immediatelyOpenNewPreviewWindow = (
project: gdProject
): WindowProxy => {
const width = project.getGameResolutionWidth();
const height = project.getGameResolutionHeight();
const left = window.screenX + window.innerWidth / 2 - width / 2;
const top = window.screenY + window.innerHeight / 2 - height / 2;
const targetId = 'GDevelopPreview' + nextPreviewWindowId++;
const previewWindow = window.open(
'about:blank',
targetId,
`width=${width},height=${height},left=${left},top=${top}`
);
if (!previewWindow) {
throw new Error(
"Can't open the preview window because of browser restrictions."
);
}
displayBlackLoadingScreenOrThrow(previewWindow);
return previewWindow;
};
export default class BrowserSWPreviewLauncher extends React.Component<
PreviewLauncherProps,
State
> {
canDoNetworkPreview = () => false;
canDoHotReload = () => false;
state = {
error: null,
};
_prepareExporter = (): Promise<{|
outputDir: string,
exporter: gdjsExporter,
browserSWFileSystem: BrowserSWFileSystem,
|}> => {
return findGDJS('preview').then(({ gdjsRoot, filesContent }) => {
console.info('[BrowserSWPreviewLauncher] GDJS found in', gdjsRoot);
const isForInGameEdition = false; // TODO: adapt for the 3D editor branch.
const baseUrl = getBrowserSWPreviewBaseUrl();
const outputDir = `${baseUrl}/${
isForInGameEdition ? 'in-game-editor-preview' : 'preview'
}`;
console.log(
'[BrowserSWPreviewLauncher] Preview will be served from:',
outputDir
);
const browserSWFileSystem = new BrowserSWFileSystem({
filesContent,
baseUrl: `${baseUrl}/`,
});
const fileSystem = assignIn(
new gd.AbstractFileSystemJS(),
browserSWFileSystem
);
const exporter = new gd.Exporter(fileSystem, gdjsRoot);
exporter.setCodeOutputDirectory(outputDir);
return {
exporter,
outputDir,
browserSWFileSystem,
};
});
};
launchPreview = async (previewOptions: PreviewOptions): Promise<any> => {
const { project, layout, externalLayout, numberOfWindows } = previewOptions;
this.setState({
error: null,
});
const debuggerIds = this.getPreviewDebuggerServer().getExistingDebuggerIds();
const shouldHotReload = previewOptions.hotReload && !!debuggerIds.length;
// Immediately 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);
} catch (error) {
console.error(
'[BrowserSWPreviewLauncher] Unable to open a new preview window - this window will be ignored:',
error
);
return null;
}
}).filter(Boolean);
try {
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.
console.error(
'[BrowserSWPreviewLauncher] Unable to start the Debugger Server for the preview:',
err
);
}
try {
const {
exporter,
outputDir,
browserSWFileSystem,
} = await this._prepareExporter();
const previewExportOptions = new gd.PreviewExportOptions(
project,
outputDir
);
previewExportOptions.setLayoutName(layout.getName());
previewExportOptions.setIsDevelopmentEnvironment(Window.isDev());
if (externalLayout) {
previewExportOptions.setExternalLayoutName(externalLayout.getName());
}
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
);
previewExportOptions.setFullLoadingScreen(
previewOptions.fullLoadingScreen
);
previewExportOptions.setNativeMobileApp(isNativeMobileApp());
previewExportOptions.setGDevelopVersionWithHash(getIDEVersionWithHash());
previewExportOptions.setCrashReportUploadLevel(
this.props.crashReportUploadLevel
);
previewExportOptions.setPreviewContext(this.props.previewContext);
previewExportOptions.setProjectTemplateSlug(project.getTemplateSlug());
previewExportOptions.setSourceGameId(this.props.sourceGameId);
if (previewOptions.inAppTutorialMessageInPreview) {
previewExportOptions.setInAppTutorialMessageInPreview(
previewOptions.inAppTutorialMessageInPreview,
previewOptions.inAppTutorialMessagePositionInPreview
);
}
if (previewOptions.fallbackAuthor) {
previewExportOptions.setFallbackAuthor(
previewOptions.fallbackAuthor.id,
previewOptions.fallbackAuthor.username
);
}
if (previewOptions.authenticatedPlayer) {
previewExportOptions.setAuthenticatedPlayer(
previewOptions.authenticatedPlayer.playerId,
previewOptions.authenticatedPlayer.playerUsername,
previewOptions.authenticatedPlayer.playerToken
);
}
if (previewOptions.captureOptions.screenshots) {
previewOptions.captureOptions.screenshots.forEach(screenshot => {
previewExportOptions.addScreenshotCapture(
screenshot.delayTimeInSeconds,
screenshot.signedUrl,
screenshot.publicUrl
);
});
}
// The token, if any, to be used to read resources on GDevelop Cloud buckets.
const gdevelopResourceToken = getGDevelopResourceJwtToken();
if (gdevelopResourceToken)
previewExportOptions.setGDevelopResourceToken(gdevelopResourceToken);
console.log(
'[BrowserSWPreviewLauncher] Exporting project for preview...'
);
exporter.exportProjectForPixiPreview(previewExportOptions);
previewExportOptions.delete();
exporter.delete();
console.log(
'[BrowserSWPreviewLauncher] Storing preview files in IndexedDB...'
);
await browserSWFileSystem.applyPendingOperations();
if (shouldHotReload) {
console.log('[BrowserSWPreviewLauncher] Triggering hot reload...');
debuggerIds.forEach(debuggerId => {
this.getPreviewDebuggerServer().sendMessage(debuggerId, {
command: 'hotReload',
});
});
} else {
console.log(
'[BrowserSWPreviewLauncher] Opening new preview window(s)...'
);
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);
browserPreviewDebuggerServer.registerCallbacks({
onErrorReceived: () => {},
onServerStateChanged: () => {},
onConnectionClosed: async ({ id }) => {
if (id !== debuggerId) {
return;
}
if (previewOptions.captureOptions) {
await this.props.onCaptureFinished(
previewOptions.captureOptions
);
}
},
onConnectionOpened: () => {},
onConnectionErrored: () => {},
onHandleParsedMessage: () => {},
});
});
}
console.log('[BrowserSWPreviewLauncher] Preview launched successfully!');
} catch (error) {
console.error(
'[BrowserSWPreviewLauncher] Error launching preview:',
error
);
this.setState({
error,
});
}
};
getPreviewDebuggerServer() {
return browserPreviewDebuggerServer;
}
render() {
const { error } = this.state;
if (error) {
return (
<BrowserPreviewErrorDialog
error={error}
onClose={() =>
this.setState({
error: null,
})
}
/>
);
}
return null;
}
}

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
// hot reload.
return this.getPreviewDebuggerServer()
.startServer()
.startServer({})
.catch(err => {
// Ignore any error when running the debugger server - the preview
// can still work without it.

View File

@@ -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,

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,
installAnalyticsEvents,
} from './Utils/Analytics/EventSender';
import { register } from './serviceWorker';
import { registerServiceWorker } from './ServiceWorkerSetup';
import './UI/icomoon-font.css'; // Styles for Icomoon font.
import optionalRequire from './Utils/OptionalRequire';
import { loadScript } from './Utils/LoadScript';
@@ -168,5 +168,4 @@ if (rootElement) {
ReactDOM.render(<Bootstrapper />, rootElement);
} else console.error('No root element defined in index.html');
// registerServiceWorker();
register();
registerServiceWorker();

View File

@@ -1,150 +0,0 @@
// 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' ||
// [::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 = process.env.PUBLIC_URL;
export function register(config) {
if (process.env.NODE_ENV === 'production' && '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', () => {
const swUrl = `${PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
} else {
console.log(
'Development build (or unsupported browser) - Service Worker disabled'
);
}
}
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);
});
}
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 => {
registration.unregister();
});
}
}