database.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. import { Tools } from "../Misc/tools.js";
  2. import { Logger } from "../Misc/logger.js";
  3. import { GetTGAHeader } from "../Misc/tga.js";
  4. import { Engine } from "../Engines/engine.js";
  5. import { WebRequest } from "../Misc/webRequest.js";
  6. // Sets the default offline provider to Babylon.js
  7. Engine.OfflineProviderFactory = (urlToScene, callbackManifestChecked, disableManifestCheck = false) => {
  8. return new Database(urlToScene, callbackManifestChecked, disableManifestCheck);
  9. };
  10. /**
  11. * Class used to enable access to IndexedDB
  12. * @see https://doc.babylonjs.com/features/featuresDeepDive/scene/optimizeCached
  13. */
  14. export class Database {
  15. /**
  16. * Gets a boolean indicating if scene must be saved in the database
  17. */
  18. get enableSceneOffline() {
  19. return this._enableSceneOffline;
  20. }
  21. /**
  22. * Gets a boolean indicating if textures must be saved in the database
  23. */
  24. get enableTexturesOffline() {
  25. return this._enableTexturesOffline;
  26. }
  27. /**
  28. * Creates a new Database
  29. * @param urlToScene defines the url to load the scene
  30. * @param callbackManifestChecked defines the callback to use when manifest is checked
  31. * @param disableManifestCheck defines a boolean indicating that we want to skip the manifest validation (it will be considered validated and up to date)
  32. */
  33. constructor(urlToScene, callbackManifestChecked, disableManifestCheck = false) {
  34. // Handling various flavors of prefixed version of IndexedDB
  35. this._idbFactory = (typeof indexedDB !== "undefined" ? indexedDB : undefined);
  36. this._currentSceneUrl = Database._ReturnFullUrlLocation(urlToScene);
  37. this._db = null;
  38. this._enableSceneOffline = false;
  39. this._enableTexturesOffline = false;
  40. this._manifestVersionFound = 0;
  41. this._mustUpdateRessources = false;
  42. this._hasReachedQuota = false;
  43. if (!Database.IDBStorageEnabled) {
  44. callbackManifestChecked(true);
  45. }
  46. else {
  47. if (disableManifestCheck) {
  48. this._enableSceneOffline = true;
  49. this._enableTexturesOffline = true;
  50. this._manifestVersionFound = 1;
  51. Tools.SetImmediate(() => {
  52. callbackManifestChecked(true);
  53. });
  54. }
  55. else {
  56. this._checkManifestFile(callbackManifestChecked);
  57. }
  58. }
  59. }
  60. _checkManifestFile(callbackManifestChecked) {
  61. const noManifestFile = () => {
  62. this._enableSceneOffline = false;
  63. this._enableTexturesOffline = false;
  64. callbackManifestChecked(false);
  65. };
  66. const createManifestURL = () => {
  67. try {
  68. // make sure we have a valid URL.
  69. if (typeof URL === "function" && this._currentSceneUrl.indexOf("http") === 0) {
  70. // we don't have the base url, so the URL string must have a protocol
  71. const url = new URL(this._currentSceneUrl);
  72. url.pathname += ".manifest";
  73. return url.toString();
  74. }
  75. }
  76. catch (e) {
  77. // defensive - if this fails for any reason, fall back to the older method
  78. }
  79. return `${this._currentSceneUrl}.manifest`;
  80. };
  81. let timeStampUsed = false;
  82. let manifestURL = createManifestURL();
  83. const xhr = new WebRequest();
  84. if (navigator.onLine) {
  85. // Adding a timestamp to by-pass browsers' cache
  86. timeStampUsed = true;
  87. manifestURL = manifestURL + (manifestURL.match(/\?/) == null ? "?" : "&") + Date.now();
  88. }
  89. xhr.open("GET", manifestURL);
  90. xhr.addEventListener("load", () => {
  91. if (xhr.status === 200 || Database._ValidateXHRData(xhr, 1)) {
  92. try {
  93. const manifestFile = JSON.parse(xhr.response);
  94. this._enableSceneOffline = manifestFile.enableSceneOffline;
  95. this._enableTexturesOffline = manifestFile.enableTexturesOffline && Database._IsUASupportingBlobStorage;
  96. if (manifestFile.version && !isNaN(parseInt(manifestFile.version))) {
  97. this._manifestVersionFound = manifestFile.version;
  98. }
  99. callbackManifestChecked(true);
  100. }
  101. catch (ex) {
  102. noManifestFile();
  103. }
  104. }
  105. else {
  106. noManifestFile();
  107. }
  108. }, false);
  109. xhr.addEventListener("error", () => {
  110. if (timeStampUsed) {
  111. timeStampUsed = false;
  112. // Let's retry without the timeStamp
  113. // It could fail when coupled with HTML5 Offline API
  114. const retryManifestURL = createManifestURL();
  115. xhr.open("GET", retryManifestURL);
  116. xhr.send();
  117. }
  118. else {
  119. noManifestFile();
  120. }
  121. }, false);
  122. try {
  123. xhr.send();
  124. }
  125. catch (ex) {
  126. Logger.Error("Error on XHR send request.");
  127. callbackManifestChecked(false);
  128. }
  129. }
  130. /**
  131. * Open the database and make it available
  132. * @param successCallback defines the callback to call on success
  133. * @param errorCallback defines the callback to call on error
  134. */
  135. open(successCallback, errorCallback) {
  136. const handleError = () => {
  137. this._isSupported = false;
  138. if (errorCallback) {
  139. errorCallback();
  140. }
  141. };
  142. if (!this._idbFactory || !(this._enableSceneOffline || this._enableTexturesOffline)) {
  143. // Your browser doesn't support IndexedDB
  144. this._isSupported = false;
  145. if (errorCallback) {
  146. errorCallback();
  147. }
  148. }
  149. else {
  150. // If the DB hasn't been opened or created yet
  151. if (!this._db) {
  152. this._hasReachedQuota = false;
  153. this._isSupported = true;
  154. const request = this._idbFactory.open("babylonjs", 1);
  155. // Could occur if user is blocking the quota for the DB and/or doesn't grant access to IndexedDB
  156. request.onerror = () => {
  157. handleError();
  158. };
  159. // executes when a version change transaction cannot complete due to other active transactions
  160. request.onblocked = () => {
  161. Logger.Error("IDB request blocked. Please reload the page.");
  162. handleError();
  163. };
  164. // DB has been opened successfully
  165. request.onsuccess = () => {
  166. this._db = request.result;
  167. successCallback();
  168. };
  169. // Initialization of the DB. Creating Scenes & Textures stores
  170. request.onupgradeneeded = (event) => {
  171. this._db = event.target.result;
  172. if (this._db) {
  173. try {
  174. this._db.createObjectStore("scenes", { keyPath: "sceneUrl" });
  175. this._db.createObjectStore("versions", { keyPath: "sceneUrl" });
  176. this._db.createObjectStore("textures", { keyPath: "textureUrl" });
  177. }
  178. catch (ex) {
  179. Logger.Error("Error while creating object stores. Exception: " + ex.message);
  180. handleError();
  181. }
  182. }
  183. };
  184. }
  185. // DB has already been created and opened
  186. else {
  187. if (successCallback) {
  188. successCallback();
  189. }
  190. }
  191. }
  192. }
  193. /**
  194. * Loads an image from the database
  195. * @param url defines the url to load from
  196. * @param image defines the target DOM image
  197. */
  198. loadImage(url, image) {
  199. const completeURL = Database._ReturnFullUrlLocation(url);
  200. const saveAndLoadImage = () => {
  201. if (!this._hasReachedQuota && this._db !== null) {
  202. // the texture is not yet in the DB, let's try to save it
  203. this._saveImageIntoDBAsync(completeURL, image);
  204. }
  205. // If the texture is not in the DB and we've reached the DB quota limit
  206. // let's load it directly from the web
  207. else {
  208. image.src = url;
  209. }
  210. };
  211. if (!this._mustUpdateRessources) {
  212. this._loadImageFromDBAsync(completeURL, image, saveAndLoadImage);
  213. }
  214. // First time we're download the images or update requested in the manifest file by a version change
  215. else {
  216. saveAndLoadImage();
  217. }
  218. }
  219. _loadImageFromDBAsync(url, image, notInDBCallback) {
  220. if (this._isSupported && this._db !== null) {
  221. let texture;
  222. const transaction = this._db.transaction(["textures"]);
  223. transaction.onabort = () => {
  224. image.src = url;
  225. };
  226. transaction.oncomplete = () => {
  227. let blobTextureURL;
  228. if (texture && typeof URL === "function") {
  229. blobTextureURL = URL.createObjectURL(texture.data);
  230. image.onerror = () => {
  231. Logger.Error("Error loading image from blob URL: " + blobTextureURL + " switching back to web url: " + url);
  232. image.src = url;
  233. };
  234. image.src = blobTextureURL;
  235. }
  236. else {
  237. notInDBCallback();
  238. }
  239. };
  240. const getRequest = transaction.objectStore("textures").get(url);
  241. getRequest.onsuccess = (event) => {
  242. texture = event.target.result;
  243. };
  244. getRequest.onerror = () => {
  245. Logger.Error("Error loading texture " + url + " from DB.");
  246. image.src = url;
  247. };
  248. }
  249. else {
  250. Logger.Error("Error: IndexedDB not supported by your browser or BabylonJS Database is not open.");
  251. image.src = url;
  252. }
  253. }
  254. _saveImageIntoDBAsync(url, image) {
  255. let blob;
  256. if (this._isSupported) {
  257. // In case of error (type not supported or quota exceeded), we're at least sending back XHR data to allow texture loading later on
  258. const generateBlobUrl = () => {
  259. let blobTextureURL;
  260. if (blob && typeof URL === "function") {
  261. try {
  262. blobTextureURL = URL.createObjectURL(blob);
  263. }
  264. catch (ex) {
  265. // Chrome is raising a type error if we're setting the oneTimeOnly parameter
  266. blobTextureURL = URL.createObjectURL(blob);
  267. }
  268. }
  269. if (blobTextureURL) {
  270. image.src = blobTextureURL;
  271. }
  272. };
  273. if (Database._IsUASupportingBlobStorage) {
  274. // Create XHR
  275. const xhr = new WebRequest();
  276. xhr.open("GET", url);
  277. xhr.responseType = "blob";
  278. xhr.addEventListener("load", () => {
  279. if (xhr.status === 200 && this._db) {
  280. // Blob as response
  281. blob = xhr.response;
  282. const transaction = this._db.transaction(["textures"], "readwrite");
  283. // the transaction could abort because of a QuotaExceededError error
  284. transaction.onabort = (event) => {
  285. try {
  286. //backwards compatibility with ts 1.0, srcElement doesn't have an "error" according to ts 1.3
  287. const srcElement = event.target;
  288. const error = srcElement.error;
  289. if (error && error.name === "QuotaExceededError") {
  290. this._hasReachedQuota = true;
  291. }
  292. }
  293. catch (ex) { }
  294. generateBlobUrl();
  295. };
  296. transaction.oncomplete = () => {
  297. generateBlobUrl();
  298. };
  299. const newTexture = { textureUrl: url, data: blob };
  300. try {
  301. // Put the blob into the dabase
  302. const addRequest = transaction.objectStore("textures").put(newTexture);
  303. addRequest.onsuccess = () => { };
  304. addRequest.onerror = () => {
  305. generateBlobUrl();
  306. };
  307. }
  308. catch (ex) {
  309. // "DataCloneError" generated by Chrome when you try to inject blob into IndexedDB
  310. if (ex.code === 25) {
  311. Database._IsUASupportingBlobStorage = false;
  312. this._enableTexturesOffline = false;
  313. }
  314. image.src = url;
  315. }
  316. }
  317. else {
  318. image.src = url;
  319. }
  320. }, false);
  321. xhr.addEventListener("error", () => {
  322. Logger.Error("Error in XHR request in BABYLON.Database.");
  323. image.src = url;
  324. }, false);
  325. xhr.send();
  326. }
  327. else {
  328. image.src = url;
  329. }
  330. }
  331. else {
  332. Logger.Error("Error: IndexedDB not supported by your browser or Babylon.js database is not open.");
  333. image.src = url;
  334. }
  335. }
  336. _checkVersionFromDB(url, versionLoaded) {
  337. const updateVersion = () => {
  338. // the version is not yet in the DB or we need to update it
  339. this._saveVersionIntoDBAsync(url, versionLoaded);
  340. };
  341. this._loadVersionFromDBAsync(url, versionLoaded, updateVersion);
  342. }
  343. _loadVersionFromDBAsync(url, callback, updateInDBCallback) {
  344. if (this._isSupported && this._db) {
  345. let version;
  346. try {
  347. const transaction = this._db.transaction(["versions"]);
  348. transaction.oncomplete = () => {
  349. if (version) {
  350. // If the version in the JSON file is different from the version in DB
  351. if (this._manifestVersionFound !== version.data) {
  352. this._mustUpdateRessources = true;
  353. updateInDBCallback();
  354. }
  355. else {
  356. callback(version.data);
  357. }
  358. }
  359. // version was not found in DB
  360. else {
  361. this._mustUpdateRessources = true;
  362. updateInDBCallback();
  363. }
  364. };
  365. transaction.onabort = () => {
  366. callback(-1);
  367. };
  368. const getRequest = transaction.objectStore("versions").get(url);
  369. getRequest.onsuccess = (event) => {
  370. version = event.target.result;
  371. };
  372. getRequest.onerror = () => {
  373. Logger.Error("Error loading version for scene " + url + " from DB.");
  374. callback(-1);
  375. };
  376. }
  377. catch (ex) {
  378. Logger.Error("Error while accessing 'versions' object store (READ OP). Exception: " + ex.message);
  379. callback(-1);
  380. }
  381. }
  382. else {
  383. Logger.Error("Error: IndexedDB not supported by your browser or Babylon.js database is not open.");
  384. callback(-1);
  385. }
  386. }
  387. _saveVersionIntoDBAsync(url, callback) {
  388. if (this._isSupported && !this._hasReachedQuota && this._db) {
  389. try {
  390. // Open a transaction to the database
  391. const transaction = this._db.transaction(["versions"], "readwrite");
  392. // the transaction could abort because of a QuotaExceededError error
  393. transaction.onabort = (event) => {
  394. try {
  395. //backwards compatibility with ts 1.0, srcElement doesn't have an "error" according to ts 1.3
  396. const error = event.target["error"];
  397. if (error && error.name === "QuotaExceededError") {
  398. this._hasReachedQuota = true;
  399. }
  400. }
  401. catch (ex) { }
  402. callback(-1);
  403. };
  404. transaction.oncomplete = () => {
  405. callback(this._manifestVersionFound);
  406. };
  407. const newVersion = { sceneUrl: url, data: this._manifestVersionFound };
  408. // Put the scene into the database
  409. const addRequest = transaction.objectStore("versions").put(newVersion);
  410. addRequest.onsuccess = () => { };
  411. addRequest.onerror = () => {
  412. Logger.Error("Error in DB add version request in BABYLON.Database.");
  413. };
  414. }
  415. catch (ex) {
  416. Logger.Error("Error while accessing 'versions' object store (WRITE OP). Exception: " + ex.message);
  417. callback(-1);
  418. }
  419. }
  420. else {
  421. callback(-1);
  422. }
  423. }
  424. /**
  425. * Loads a file from database
  426. * @param url defines the URL to load from
  427. * @param sceneLoaded defines a callback to call on success
  428. * @param progressCallBack defines a callback to call when progress changed
  429. * @param errorCallback defines a callback to call on error
  430. * @param useArrayBuffer defines a boolean to use array buffer instead of text string
  431. */
  432. loadFile(url, sceneLoaded, progressCallBack, errorCallback, useArrayBuffer) {
  433. const completeUrl = Database._ReturnFullUrlLocation(url);
  434. const saveAndLoadFile = () => {
  435. // the scene is not yet in the DB, let's try to save it
  436. this._saveFileAsync(completeUrl, sceneLoaded, progressCallBack, useArrayBuffer, errorCallback);
  437. };
  438. this._checkVersionFromDB(completeUrl, (version) => {
  439. if (version !== -1) {
  440. if (!this._mustUpdateRessources) {
  441. this._loadFileAsync(completeUrl, sceneLoaded, saveAndLoadFile);
  442. }
  443. else {
  444. this._saveFileAsync(completeUrl, sceneLoaded, progressCallBack, useArrayBuffer, errorCallback);
  445. }
  446. }
  447. else {
  448. if (errorCallback) {
  449. errorCallback();
  450. }
  451. }
  452. });
  453. }
  454. _loadFileAsync(url, callback, notInDBCallback) {
  455. if (this._isSupported && this._db) {
  456. let targetStore;
  457. if (url.indexOf(".babylon") !== -1) {
  458. targetStore = "scenes";
  459. }
  460. else {
  461. targetStore = "textures";
  462. }
  463. let file;
  464. const transaction = this._db.transaction([targetStore]);
  465. transaction.oncomplete = () => {
  466. if (file) {
  467. callback(file.data);
  468. }
  469. // file was not found in DB
  470. else {
  471. notInDBCallback();
  472. }
  473. };
  474. transaction.onabort = () => {
  475. notInDBCallback();
  476. };
  477. const getRequest = transaction.objectStore(targetStore).get(url);
  478. getRequest.onsuccess = (event) => {
  479. file = event.target.result;
  480. };
  481. getRequest.onerror = () => {
  482. Logger.Error("Error loading file " + url + " from DB.");
  483. notInDBCallback();
  484. };
  485. }
  486. else {
  487. Logger.Error("Error: IndexedDB not supported by your browser or BabylonJS Database is not open.");
  488. callback();
  489. }
  490. }
  491. _saveFileAsync(url, callback, progressCallback, useArrayBuffer, errorCallback) {
  492. if (this._isSupported) {
  493. let targetStore;
  494. if (url.indexOf(".babylon") !== -1) {
  495. targetStore = "scenes";
  496. }
  497. else {
  498. targetStore = "textures";
  499. }
  500. // Create XHR
  501. const xhr = new WebRequest();
  502. let fileData;
  503. xhr.open("GET", url + (url.match(/\?/) == null ? "?" : "&") + Date.now());
  504. if (useArrayBuffer) {
  505. xhr.responseType = "arraybuffer";
  506. }
  507. if (progressCallback) {
  508. xhr.onprogress = progressCallback;
  509. }
  510. xhr.addEventListener("load", () => {
  511. if (xhr.status === 200 || (xhr.status < 400 && Database._ValidateXHRData(xhr, !useArrayBuffer ? 1 : 6))) {
  512. // Blob as response
  513. fileData = !useArrayBuffer ? xhr.responseText : xhr.response;
  514. if (!this._hasReachedQuota && this._db) {
  515. // Open a transaction to the database
  516. const transaction = this._db.transaction([targetStore], "readwrite");
  517. // the transaction could abort because of a QuotaExceededError error
  518. transaction.onabort = (event) => {
  519. try {
  520. //backwards compatibility with ts 1.0, srcElement doesn't have an "error" according to ts 1.3
  521. const error = event.target["error"];
  522. if (error && error.name === "QuotaExceededError") {
  523. this._hasReachedQuota = true;
  524. }
  525. }
  526. catch (ex) { }
  527. callback(fileData);
  528. };
  529. transaction.oncomplete = () => {
  530. callback(fileData);
  531. };
  532. let newFile;
  533. if (targetStore === "scenes") {
  534. newFile = { sceneUrl: url, data: fileData, version: this._manifestVersionFound };
  535. }
  536. else {
  537. newFile = { textureUrl: url, data: fileData };
  538. }
  539. try {
  540. // Put the scene into the database
  541. const addRequest = transaction.objectStore(targetStore).put(newFile);
  542. addRequest.onsuccess = () => { };
  543. addRequest.onerror = () => {
  544. Logger.Error("Error in DB add file request in BABYLON.Database.");
  545. };
  546. }
  547. catch (ex) {
  548. callback(fileData);
  549. }
  550. }
  551. else {
  552. callback(fileData);
  553. }
  554. }
  555. else {
  556. if (xhr.status >= 400 && errorCallback) {
  557. errorCallback(xhr);
  558. }
  559. else {
  560. callback();
  561. }
  562. }
  563. }, false);
  564. xhr.addEventListener("error", () => {
  565. Logger.Error("error on XHR request.");
  566. errorCallback && errorCallback();
  567. }, false);
  568. xhr.send();
  569. }
  570. else {
  571. Logger.Error("Error: IndexedDB not supported by your browser or Babylon.js database is not open.");
  572. errorCallback && errorCallback();
  573. }
  574. }
  575. /**
  576. * Validates if xhr data is correct
  577. * @param xhr defines the request to validate
  578. * @param dataType defines the expected data type
  579. * @returns true if data is correct
  580. */
  581. static _ValidateXHRData(xhr, dataType = 7) {
  582. // 1 for text (.babylon, manifest and shaders), 2 for TGA, 4 for DDS, 7 for all
  583. try {
  584. if (dataType & 1) {
  585. if (xhr.responseText && xhr.responseText.length > 0) {
  586. return true;
  587. }
  588. else if (dataType === 1) {
  589. return false;
  590. }
  591. }
  592. if (dataType & 2) {
  593. // Check header width and height since there is no "TGA" magic number
  594. const tgaHeader = GetTGAHeader(xhr.response);
  595. if (tgaHeader.width && tgaHeader.height && tgaHeader.width > 0 && tgaHeader.height > 0) {
  596. return true;
  597. }
  598. else if (dataType === 2) {
  599. return false;
  600. }
  601. }
  602. if (dataType & 4) {
  603. // Check for the "DDS" magic number
  604. const ddsHeader = new Uint8Array(xhr.response, 0, 3);
  605. if (ddsHeader[0] === 68 && ddsHeader[1] === 68 && ddsHeader[2] === 83) {
  606. return true;
  607. }
  608. else {
  609. return false;
  610. }
  611. }
  612. }
  613. catch (e) {
  614. // Global protection
  615. }
  616. return false;
  617. }
  618. }
  619. /** Gets a boolean indicating if the user agent supports blob storage (this value will be updated after creating the first Database object) */
  620. Database._IsUASupportingBlobStorage = true;
  621. /**
  622. * Gets a boolean indicating if Database storage is enabled (off by default)
  623. */
  624. Database.IDBStorageEnabled = false;
  625. Database._ParseURL = (url) => {
  626. const a = document.createElement("a");
  627. a.href = url;
  628. const urlWithoutHash = url.substring(0, url.lastIndexOf("#"));
  629. const fileName = url.substring(urlWithoutHash.lastIndexOf("/") + 1, url.length);
  630. const absLocation = url.substring(0, url.indexOf(fileName, 0));
  631. return absLocation;
  632. };
  633. Database._ReturnFullUrlLocation = (url) => {
  634. if (url.indexOf("http:/") === -1 && url.indexOf("https:/") === -1 && typeof window !== "undefined") {
  635. return Database._ParseURL(window.location.href) + url;
  636. }
  637. else {
  638. return url;
  639. }
  640. };
  641. //# sourceMappingURL=database.js.map