Skip to content

Commit 2f00102

Browse files
authored
Fix packaging very large projects as HTML files in Chrome (TurboWarp#862)
Actually fixes TurboWarp#528 TurboWarp#861 fixed running large projects in Chrome, but packaging still used string concatenation so it remained broken. Now the concatenation part is done using TextEncoder & Uint8Arrays and a template tag function to keep it readable. This time I have actually tested it with a 1.0GB sb3. Breaking Node API change: The data property returned by Packager#package() is now always a Uint8Array instead of sometimes string and sometimes ArrayBuffer.
1 parent e366607 commit 2f00102

File tree

4 files changed

+118
-9
lines changed

4 files changed

+118
-9
lines changed

node-api-docs/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ const filename = result.filename;
126126
// MIME type of the packaged project. Either "text/html" or "application/zip"
127127
const type = result.type;
128128

129-
// The packaged project's data. Will be either a string (for type text/html) or ArrayBuffer (for type application/zip).
129+
// The packaged project's data. Will always be a Uint8Array.
130130
const data = result.data;
131131
```
132132

src/packager/encode-big-string.js

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* @template T
3+
* @param {T[]} destination
4+
* @param {T[]} newItems
5+
*/
6+
const concatInPlace = (destination, newItems) => {
7+
for (const item of newItems) {
8+
destination.push(item);
9+
}
10+
};
11+
12+
/**
13+
* @param {unknown} value String, number, Uint8Array, etc. or a recursive array of them
14+
* @returns {Uint8Array[]} UTF-8 arrays, in order
15+
*/
16+
const encodeComponent = (value) => {
17+
if (typeof value === 'string') {
18+
return [
19+
new TextEncoder().encode(value)
20+
];
21+
} else if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'undefined' || value === null) {
22+
return [
23+
new TextEncoder().encode(String(value))
24+
];
25+
} else if (Array.isArray(value)) {
26+
const result = [];
27+
for (const i of value) {
28+
concatInPlace(result, encodeComponent(i));
29+
}
30+
return result;
31+
} else {
32+
throw new Error(`Unknown value in encodeComponent: ${value}`);
33+
}
34+
};
35+
36+
/**
37+
* Tagged template function to generate encoded UTF-8 without string concatenation as Chrome cannot handle
38+
* strings that are longer than 0x1fffffe8 characters.
39+
* @param {TemplateStringsArray} strings
40+
* @param {unknown[]} values
41+
* @returns {Uint8Array}
42+
*/
43+
const encodeBigString = (strings, ...values) => {
44+
/** @type {Uint8Array[]} */
45+
const encodedChunks = [];
46+
47+
for (let i = 0; i < strings.length - 1; i++) {
48+
concatInPlace(encodedChunks, encodeComponent(strings[i]));
49+
concatInPlace(encodedChunks, encodeComponent(values[i]));
50+
}
51+
concatInPlace(encodedChunks, encodeComponent(strings[strings.length - 1]));
52+
53+
let totalByteLength = 0;
54+
for (let i = 0; i < encodedChunks.length; i++) {
55+
totalByteLength += encodedChunks[i].byteLength;
56+
}
57+
58+
const resultBuffer = new Uint8Array(totalByteLength);
59+
for (let i = 0, j = 0; i < encodedChunks.length; i++) {
60+
resultBuffer.set(encodedChunks[i], j);
61+
j += encodedChunks[i].byteLength;
62+
}
63+
return resultBuffer;
64+
};
65+
66+
export default encodeBigString;

src/packager/packager.js

+10-8
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {APP_NAME, WEBSITE, COPYRIGHT_NOTICE, ACCENT_COLOR} from './brand';
1111
import {OutdatedPackagerError} from '../common/errors';
1212
import {darken} from './colors';
1313
import {Adapter} from './adapter';
14+
import encodeBigString from './encode-big-string';
1415

1516
const PROGRESS_LOADED_SCRIPTS = 0.1;
1617

@@ -881,7 +882,7 @@ cd "$(dirname "$0")"
881882
}
882883

883884
async generateGetProjectData () {
884-
let result = '';
885+
const result = [];
885886
let getProjectDataFunction = '';
886887
let isZip = false;
887888
let storageProgressStart;
@@ -895,7 +896,7 @@ cd "$(dirname "$0")"
895896
const projectData = new Uint8Array(this.project.arrayBuffer);
896897

897898
// keep this up-to-date with base85.js
898-
result += `
899+
result.push(`
899900
<script>
900901
const getBase85DecodeValue = (code) => {
901902
if (code === 0x28) code = 0x3c;
@@ -926,15 +927,15 @@ cd "$(dirname "$0")"
926927
handleError(e);
927928
}
928929
};
929-
</script>`;
930+
</script>`);
930931

931932
// To avoid unnecessary padding, this should be a multiple of 4.
932933
const CHUNK_SIZE = 1024 * 64;
933934

934935
for (let i = 0; i < projectData.length; i += CHUNK_SIZE) {
935936
const projectChunk = projectData.subarray(i, i + CHUNK_SIZE);
936937
const base85 = encode(projectChunk);
937-
result += `<script data="${base85}">decodeChunk(${projectChunk.length})</script>\n`;
938+
result.push(`<script data="${base85}">decodeChunk(${projectChunk.length})</script>\n`);
938939
}
939940

940941
getProjectDataFunction = `() => {
@@ -978,7 +979,7 @@ cd "$(dirname "$0")"
978979
})`;
979980
}
980981

981-
result += `
982+
result.push(`
982983
<script>
983984
const getProjectData = (function() {
984985
const storage = scaffolding.storage;
@@ -1024,7 +1025,8 @@ cd "$(dirname "$0")"
10241025
);
10251026
return ${getProjectDataFunction};`}
10261027
})();
1027-
</script>`;
1028+
</script>`);
1029+
10281030
return result;
10291031
}
10301032

@@ -1107,7 +1109,7 @@ cd "$(dirname "$0")"
11071109
this.ensureNotAborted();
11081110
await this.loadResources();
11091111
this.ensureNotAborted();
1110-
const html = `<!DOCTYPE html>
1112+
const html = encodeBigString`<!DOCTYPE html>
11111113
<!-- Created with ${WEBSITE} -->
11121114
<html>
11131115
<head>
@@ -1565,7 +1567,7 @@ cd "$(dirname "$0")"
15651567
this.ensureNotAborted();
15661568
return {
15671569
data: await zip.generateAsync({
1568-
type: 'arraybuffer',
1570+
type: 'uint8array',
15691571
compression: 'DEFLATE',
15701572
// Use UNIX permissions so that executable bits are properly set for macOS and Linux
15711573
platform: 'UNIX'

test/p4/encode-big-string.test.js

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import encodeBigString from "../../src/packager/encode-big-string";
2+
3+
test('simple behavior', () => {
4+
expect(encodeBigString``).toEqual(new Uint8Array([]));
5+
expect(encodeBigString`abc`).toEqual(new Uint8Array([97, 98, 99]));
6+
expect(encodeBigString`a${'bc'}`).toEqual(new Uint8Array([97, 98, 99]));
7+
expect(encodeBigString`${'ab'}c`).toEqual(new Uint8Array([97, 98, 99]));
8+
expect(encodeBigString`${'abc'}`).toEqual(new Uint8Array([97, 98, 99]));
9+
expect(encodeBigString`1${'a'}2${'b'}3${'c'}4`).toEqual(new Uint8Array([49, 97, 50, 98, 51, 99, 52]));
10+
expect(encodeBigString`${''}`).toEqual(new Uint8Array([]));
11+
});
12+
13+
test('non-string primitives', () => {
14+
expect(encodeBigString`${1}`).toEqual(new Uint8Array([49]));
15+
expect(encodeBigString`${false}`).toEqual(new Uint8Array([102, 97, 108, 115, 101]));
16+
expect(encodeBigString`${true}`).toEqual(new Uint8Array([116, 114, 117, 101]));
17+
expect(encodeBigString`${null}`).toEqual(new Uint8Array([110, 117, 108, 108]));
18+
expect(encodeBigString`${undefined}`).toEqual(new Uint8Array([117, 110, 100, 101, 102, 105, 110, 101, 100]));
19+
});
20+
21+
test('array', () => {
22+
expect(encodeBigString`${[]}`).toEqual(new Uint8Array([]));
23+
expect(encodeBigString`${['a', 'b', 'c']}`).toEqual(new Uint8Array([97, 98, 99]));
24+
expect(encodeBigString`${[[[['a'], [['b']], 'c']]]}`).toEqual(new Uint8Array([97, 98, 99]));
25+
});
26+
27+
// skipping for now because very slow
28+
test.skip('very big string', () => {
29+
const MAX_LENGTH = 0x1fffffe8;
30+
const maxLength = 'a'.repeat(MAX_LENGTH);
31+
expect(() => maxLength + 'a').toThrow(/Invalid string length/);
32+
const encoded = encodeBigString`${maxLength}aaaaa`;
33+
expect(encoded.byteLength).toBe(MAX_LENGTH + 5);
34+
35+
// very hot loop, don't call into expect if we don't need to
36+
for (let i = 0; i < encoded.length; i++) {
37+
if (encoded[i] !== 97) {
38+
throw new Error(`Wrong encoding at ${i}`);
39+
}
40+
}
41+
});

0 commit comments

Comments
 (0)