diff --git a/package-lock.json b/package-lock.json index 4a0c7a90..a4178113 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.6.0", "license": "GPL-3.0-or-later", "dependencies": { + "@msgpack/msgpack": "^3.1.2", "ajv": "^6.10.2", "circular-json": "^0.5.9", "clipboard-copy": "^3.1.0", @@ -658,6 +659,15 @@ "node": ">= 8" } }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.2.tgz", + "integrity": "sha512-JEW4DEtBzfe8HvUYecLU9e6+XJnKDlUAIve8FvPzF3Kzs6Xo/KuZkZJsDH0wJXl/qEZbeeE7edxDNY3kMs39hQ==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index 2e8ae979..e88260ae 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "package-all": "gulp --cwd gulp package.standalone.all" }, "dependencies": { + "@msgpack/msgpack": "^3.1.2", "ajv": "^6.10.2", "circular-json": "^0.5.9", "clipboard-copy": "^3.1.0", diff --git a/src/js/core/compression.ts b/src/js/core/compression.ts new file mode 100644 index 00000000..cb43daa5 --- /dev/null +++ b/src/js/core/compression.ts @@ -0,0 +1,49 @@ +export interface Compression { + compress(data: unknown): Promise; + decompress(data: Uint8Array): Promise; +} + +type CompressionWorkerResponse = { result: T; error: null } | { result: null; error: Error }; + +export class DefaultCompression implements Compression { + compress(data: unknown): Promise { + const { promise, reject, resolve } = Promise.withResolvers(); + + // NOTE: new URL(...) has to be inlined for webpack to process it correctly + const worker = new Worker(new URL("../webworkers/compression", import.meta.url)); + worker.addEventListener("error", ev => reject(ev.message)); + + worker.addEventListener("message", ev => { + const response = ev.data as CompressionWorkerResponse; + if (response.error !== null) { + reject(response.error); + return; + } + + resolve(response.result); + }); + + worker.postMessage(data); + return promise; + } + + decompress(data: Uint8Array): Promise { + const { promise, reject, resolve } = Promise.withResolvers(); + + const worker = new Worker(new URL("../webworkers/decompression", import.meta.url)); + worker.addEventListener("error", ev => reject(new Error(ev.message))); + + worker.addEventListener("message", ev => { + const response = ev.data as CompressionWorkerResponse; + if (response.error !== null) { + reject(response.error); + return; + } + + resolve(response.result); + }); + + worker.postMessage(data, [data.buffer]); + return promise; + } +} diff --git a/src/js/mods/modloader.ts b/src/js/mods/modloader.ts index 26581b0e..a8971c60 100644 --- a/src/js/mods/modloader.ts +++ b/src/js/mods/modloader.ts @@ -108,7 +108,9 @@ export class ModLoader { const modules = import.meta.webpackContext("../", { recursive: true, regExp: /\.[jt]sx?$/, - exclude: /\.d\.ts$/, + // NOTE: Worker scripts are executed if not explicitly excluded, which causes + // infinite recursion! + exclude: /\/webworkers\/|\.d\.ts$/, }); Array.from(modules.keys()).forEach(key => { diff --git a/src/js/platform/storage.ts b/src/js/platform/storage.ts index d947dc33..c69df6dc 100644 --- a/src/js/platform/storage.ts +++ b/src/js/platform/storage.ts @@ -1,16 +1,26 @@ import { Application } from "@/application"; +import { Compression, DefaultCompression } from "@/core/compression"; import { FsError } from "./fs_error"; export const STORAGE_SAVES = "saves"; export const STORAGE_MOD_PREFIX = "mod/"; +interface FsJob { + type: string; + filename?: string; + contents?: Uint8Array; + extension?: string; +} + export class Storage { readonly app: Application; readonly id: string; + readonly compression: Compression; - constructor(app: Application, id: string) { + constructor(app: Application, id: string, compression?: Compression) { this.app = app; this.id = id; + this.compression = compression ?? new DefaultCompression(); } /** @@ -20,18 +30,22 @@ export class Storage { return this.invokeFsJob({ type: "initialize" }); } - /** - * Writes a string to a file asynchronously - */ - writeFileAsync(filename: string, contents: unknown): Promise { - return this.invokeFsJob({ type: "write", filename, contents }); - } - /** * Reads a string asynchronously */ readFileAsync(filename: string): Promise { - return this.invokeFsJob({ type: "read", filename }); + return this.invokeFsJob({ type: "read", filename }).then(contents => + this.compression.decompress(contents) + ); + } + + /** + * Writes a string to a file asynchronously + */ + writeFileAsync(filename: string, contents: unknown): Promise { + return this.compression + .compress(contents) + .then(contents => this.invokeFsJob({ type: "write", filename, contents })); } /** @@ -46,7 +60,9 @@ export class Storage { * decompressed file contents, or undefined if the operation was canceled */ requestOpenFile(extension: string): Promise { - return this.invokeFsJob({ type: "open-external", extension }); + return this.invokeFsJob({ type: "open-external", extension }).then(contents => + contents ? this.compression.decompress(contents) : undefined + ); } /** @@ -55,10 +71,12 @@ export class Storage { * that file. */ requestSaveFile(filename: string, contents: unknown): Promise { - return this.invokeFsJob({ type: "save-external", filename, contents }); + return this.compression + .compress(contents) + .then(contents => this.invokeFsJob({ type: "save-external", filename, contents })); } - private invokeFsJob(data: object) { + private invokeFsJob(data: FsJob) { return ipcRenderer .invoke("fs-job", { id: this.id, diff --git a/src/js/webworkers/compression.ts b/src/js/webworkers/compression.ts new file mode 100644 index 00000000..2d548fb4 --- /dev/null +++ b/src/js/webworkers/compression.ts @@ -0,0 +1,20 @@ +/// + +import { encode } from "@msgpack/msgpack"; + +async function compress(data: unknown): Promise { + const input = new Blob([encode(data)]).stream(); + const gzip = new CompressionStream("gzip"); + const response = new Response(input.pipeThrough(gzip)); + + return new Uint8Array(await response.arrayBuffer()); +} + +self.addEventListener("message", async ev => { + try { + const result = await compress(ev.data); + self.postMessage({ result, error: null }, [result.buffer]); + } catch (err) { + self.postMessage({ result: null, error: err }); + } +}); diff --git a/src/js/webworkers/decompression.ts b/src/js/webworkers/decompression.ts new file mode 100644 index 00000000..c9dde14e --- /dev/null +++ b/src/js/webworkers/decompression.ts @@ -0,0 +1,24 @@ +/// + +import { decodeAsync } from "@msgpack/msgpack"; + +async function decompress(data: Uint8Array): Promise { + const input = new Blob([data]).stream(); + const gunzip = new DecompressionStream("gzip"); + + return await decodeAsync(input.pipeThrough(gunzip)); +} + +self.addEventListener("message", async ev => { + if (!(ev.data instanceof Uint8Array)) { + self.postMessage({ result: null, error: new Error("Incoming data must be of type Uint8Array") }); + return; + } + + try { + const result = await decompress(ev.data); + self.postMessage({ result, error: null }); + } catch (err) { + self.postMessage({ result: null, error: err }); + } +}); diff --git a/src/js/webworkers/tsconfig.json b/src/js/webworkers/tsconfig.json deleted file mode 100644 index 74dc2509..00000000 --- a/src/js/webworkers/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": ["../../tsconfig"], - "compilerOptions": { - "lib": ["WebWorker"] - } -} diff --git a/src/tsconfig.json b/src/tsconfig.json index b1af40a1..36911449 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -6,7 +6,7 @@ "module": "es2022", "moduleResolution": "bundler", "noEmit": true, - "target": "ES2022", + "target": "ES2024", /* JSX Compilation */ "paths": { "@/*": ["./js/*"]