Skip to content

Commit f62437f

Browse files
committed
[breaking-change] Calculate download progress correctly.
It turns out that `ReadableStream` is not (by itself) a way to compute download progress: the `Content-Length` header is post-Zstd, causing download progress to go above 100%, and our files are particularly in need of being compressed, given the high ratios achievable. This commit changes the interface to export total artifact size from the resource module. Instead of `() => import('app-resources.js')`, the import should now be used as simply `import('app-resources.js')`, since the resource file itself is very small.
1 parent d4a8524 commit f62437f

File tree

5 files changed

+118
-156
lines changed

5 files changed

+118
-156
lines changed

bin/pack-resources.js

Lines changed: 82 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -3,88 +3,106 @@
33
import { readdir, readFile, writeFile, stat } from 'fs/promises';
44
import { createTar } from 'nanotar';
55

6-
async function packModules(root, urlRoot) {
7-
const files = await readdir(root, { withFileTypes: true });
8-
const packedData = [`{\n`];
9-
for (const file of files) {
10-
if (file.isFile() && file.name.endsWith('.wasm')) {
11-
packedData.push(` ${JSON.stringify(file.name)}: `);
12-
packedData.push(`new URL(${JSON.stringify(urlRoot + file.name)}, import.meta.url)`);
13-
packedData.push(`,\n`);
14-
}
15-
}
16-
packedData.push(`}`);
17-
return packedData;
18-
}
19-
20-
async function collectDirectory(root, dirPath = '', packedData = []) {
21-
const files = await readdir(`${root}/${dirPath}`, { withFileTypes: true });
22-
for (const file of files) {
23-
const filePath = dirPath === '' ? file.name : `${dirPath}/${file.name}`;
24-
const fileStats = await stat(`${root}/${filePath}`);
25-
if (fileStats.isDirectory()) {
26-
packedData.push({name: filePath});
27-
await collectDirectory(root, filePath, packedData);
28-
} else if (fileStats.isFile()) {
29-
packedData.push({name: filePath, data: await readFile(`${root}/${filePath}`)});
30-
} else {
31-
console.error(`Unsupported '${filePath}'!`);
32-
process.exit(2);
33-
}
34-
}
35-
return packedData;
36-
}
37-
386
const args = process.argv.slice(2);
397
if (!(args.length >= 2 && args.length <= 4)) {
40-
console.error(`Usage: yowasp-pack-resources <resources.js> <gen-directory> [<share-directory>] [<share-root>]`);
8+
console.error(`Usage: yowasp-pack-resources <resources.js> <wasm-directory> [<share-directory>] [<share-root>]`);
419
process.exit(1);
4210
}
43-
4411
const resourceFilePath = args[0];
45-
const genDirectory = args[1];
12+
const wasmDirectory = args[1];
4613
const shareDirectory = args[2];
4714
const shareRoot = args[3] || 'share';
4815

16+
let totalSize = 0;
17+
18+
async function fetchExpr(filePath, fileURL) {
19+
const fileStats = await stat(filePath);
20+
totalSize += fileStats.size;
21+
return `fetch(new URL(${JSON.stringify(fileURL)}, import.meta.url))`;
22+
}
23+
4924
let output = `\
5025
import { parseTar } from 'nanotar';
5126
52-
function unpackResources(url) {
53-
function defaultFetchFn(url) {
54-
return fetch(url).then((resp) => resp.arrayBuffer());
27+
function compileWasmModule(response) {
28+
if (WebAssembly.compileStreaming !== undefined) {
29+
// Node.js does not have 'WebAssembly.{compile,instantiate}Streaming'.
30+
return WebAssembly.compileStreaming(response);
31+
} else {
32+
return WebAssembly.compile(response.arrayBuffer());
5533
}
34+
}
5635
57-
return async (fetchFn = defaultFetchFn) => {
58-
const root = {};
59-
for (const tarEntry of parseTar(await fetchFn(url))) {
60-
const nameParts = tarEntry.name.split('/');
61-
const dirNames = nameParts.slice(0, -1);
62-
const fileName = nameParts[nameParts.length - 1];
63-
let dir = root;
64-
for (const dirName of dirNames)
65-
dir = dir[dirName];
66-
if (tarEntry.type === 'directory') {
67-
dir[fileName] = {};
68-
} else {
69-
dir[fileName] = tarEntry.data;
70-
}
36+
function unpackTarFilesystem(buffer) {
37+
const root = {};
38+
for (const tarEntry of parseTar(buffer)) {
39+
const nameParts = tarEntry.name.split('/');
40+
const dirNames = nameParts.slice(0, -1);
41+
const fileName = nameParts[nameParts.length - 1];
42+
let dir = root;
43+
for (const dirName of dirNames)
44+
dir = dir[dirName];
45+
if (tarEntry.type === 'directory') {
46+
dir[fileName] = {};
47+
} else {
48+
dir[fileName] = tarEntry.data;
7149
}
72-
return root;
73-
};
50+
}
51+
return root;
7452
}
7553
54+
const modules = async (fetch) => ({
55+
`;
56+
57+
for (const dirent of await readdir(wasmDirectory, { withFileTypes: true })) {
58+
if (dirent.isFile() && dirent.name.endsWith('.wasm')) {
59+
const filePath = `${dirent.parentPath}/${dirent.name}`;
60+
output += `\
61+
${JSON.stringify(dirent.name)}: await ${await fetchExpr(filePath, `./${dirent.name}`)}
62+
.then(compileWasmModule),
7663
`;
77-
const moduleObject = (await packModules(genDirectory, './')).flat(Infinity).join('');
78-
output += `export const modules = ${moduleObject};\n\n`;
79-
if (shareDirectory) {
64+
}
65+
}
66+
67+
output += `\
68+
});
69+
70+
const filesystem = async (fetch) => ({
71+
`;
72+
73+
if (shareDirectory !== undefined) {
74+
const tarEntries = [];
75+
for (const dirent of await readdir(shareDirectory, { withFileTypes: true, recursive: true })) {
76+
if (dirent.isDirectory()) {
77+
tarEntries.push({name: dirent.name});
78+
} else if (dirent.isFile()) {
79+
const name = `${dirent.parentPath}/${dirent.name}`.replace(`${shareDirectory}/`, '');
80+
const data = await readFile(`${dirent.parentPath}/${dirent.name}`);
81+
tarEntries.push({name, data});
82+
} else {
83+
console.error(`Unsupported type of '${dirent.name}'!`);
84+
process.exit(2);
85+
}
86+
}
87+
88+
const tarData = createTar(tarEntries);
8089
const tarFilePath = resourceFilePath.replace(/\.js$/, '.tar');
81-
await writeFile(tarFilePath, createTar(await collectDirectory(shareDirectory)));
90+
await writeFile(tarFilePath, tarData);
91+
8292
const tarFileName = tarFilePath.replace(/^.+\//, '');
83-
const resourceObject = `unpackResources(new URL('./${tarFileName}', import.meta.url))`;
84-
output += `export const filesystem = {\n`;
85-
output += ` ${shareRoot}: ${resourceObject},\n`;
86-
output += `};\n`;
87-
} else {
88-
output += `export const filesystem = {};\n`;
93+
output += `\
94+
${JSON.stringify(shareRoot)}: await ${await fetchExpr(tarFilePath, `./${tarFileName}`)}
95+
.then((resp) => resp.arrayBuffer())
96+
.then(unpackTarFilesystem)
97+
`;
8998
}
99+
100+
output += `\
101+
});
102+
103+
const totalSize = ${totalSize};
104+
105+
export { modules, filesystem, totalSize };
106+
`;
107+
90108
await writeFile(resourceFilePath, output);

lib/api.js

Lines changed: 29 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -4,91 +4,14 @@ import { lineBuffered } from './util.js';
44

55
export { Exit } from './wasi-virt.js';
66

7-
async function fetchObject(obj, fetchFn) {
8-
// Mutate the object being fetched, to avoid re-fetches within the same session.
9-
// Do this in parallel to avoid head-of-line blocking.
10-
const promises = [];
11-
for (const [key, value] of Object.entries(obj)) {
12-
if (typeof value === "string" || value instanceof Uint8Array) {
13-
promises.push(Promise.resolve([key, value]));
14-
} else if (value instanceof URL) {
15-
promises.push(fetchFn(value).then((fetched) => [key, fetched]));
16-
} else if (value instanceof Function) {
17-
promises.push(await value(fetchFn).then((fetched) => [key, fetched]));
18-
} else {
19-
promises.push(fetchObject(value, fetchFn).then((fetched) => [key, fetched]));
20-
}
21-
}
22-
for (const [key, value] of await Promise.all(promises))
23-
obj[key] = value;
24-
return obj;
25-
}
26-
27-
function fetchWebAssembly(url, monitorProgress) {
28-
const response = fetch(url).then(monitorProgress);
29-
if (WebAssembly.compileStreaming !== undefined) {
30-
return response.then(WebAssembly.compileStreaming);
31-
} else {
32-
// Node doesn't have `{compile,instantiate}Streaming`.
33-
return response.then((resp) => resp.arrayBuffer()).then(WebAssembly.compile);
34-
}
35-
}
36-
37-
function fetchUint8Array(url, monitorProgress) {
38-
const response = fetch(url).then(monitorProgress);
39-
return response.then((resp) => resp.arrayBuffer()).then((buf) => new Uint8Array(buf));
40-
}
41-
42-
function fetchResources({ modules, filesystem }, fetchProgress, application) {
43-
/** @type {(response: Response) => Response} */
44-
let monitorProgress = (response) => response;
45-
if (fetchProgress !== undefined) {
46-
const progress = new Map();
47-
const notifyProgress = () => {
48-
let cumTotalLength = 0, cumDoneLength = 0;
49-
for (const { totalLength, doneLength } of progress.values()) {
50-
cumTotalLength += totalLength;
51-
cumDoneLength += doneLength;
52-
}
53-
fetchProgress({
54-
source: application,
55-
totalLength: cumTotalLength,
56-
doneLength: cumDoneLength
57-
});
58-
};
59-
monitorProgress = (response) => {
60-
let totalLength = +response.headers.get('content-length');
61-
let doneLength = 0;
62-
progress.set(response, { totalLength, doneLength });
63-
notifyProgress();
64-
/** @type {TransformStream<Uint8Array, Uint8Array>} */
65-
const monitorStream = new TransformStream({
66-
transform(chunk, controller) {
67-
controller.enqueue(chunk);
68-
doneLength += chunk.length;
69-
progress.set(response, { totalLength, doneLength });
70-
notifyProgress();
71-
}
72-
});
73-
return new Response(response.body.pipeThrough(monitorStream), response);
74-
};
75-
}
76-
return Promise.all([
77-
fetchObject(modules, (url) => fetchWebAssembly(url, monitorProgress)),
78-
fetchObject(filesystem, (url) => fetchUint8Array(url, monitorProgress))
79-
]).then(([modules, filesystem]) => {
80-
return { modules, filesystem };
81-
});
82-
}
83-
847
export class Application {
85-
#resources;
8+
#resourceModule;
869
#resourceData;
8710
#instantiate;
8811
#argv0;
8912

90-
constructor(resources, instantiate, argv0) {
91-
this.#resources = resources;
13+
constructor(resourceModule, instantiate, argv0) {
14+
this.#resourceModule = resourceModule;
9215
this.#resourceData = null;
9316
this.#instantiate = instantiate;
9417
this.#argv0 = argv0;
@@ -98,6 +21,29 @@ export class Application {
9821
return this.#argv0;
9922
}
10023

24+
async #fetchResources(fetchProgress) {
25+
const resourceModule = await this.#resourceModule;
26+
let fetchFn = fetch;
27+
if (fetchProgress !== undefined) {
28+
const status = { source: this, totalLength: resourceModule.totalSize, doneLength: 0 };
29+
fetchProgress(status);
30+
fetchFn = (input, init) => fetch(input, init).then((response) => {
31+
return new Response(response.body.pipeThrough(new TransformStream({
32+
transform(chunk, controller) {
33+
controller.enqueue(chunk);
34+
status.doneLength += chunk.length;
35+
fetchProgress(status);
36+
}
37+
})), response);
38+
});
39+
}
40+
const [modules, filesystem] = await Promise.all([
41+
resourceModule.modules(fetchFn),
42+
resourceModule.filesystem(fetchFn),
43+
]);
44+
this.#resourceData = { modules, filesystem };
45+
}
46+
10147
// The `printLine` option is deprecated and not documented but still accepted for compatibility.
10248
run(args = null, files = {}, options = {}) {
10349
if (this.#resourceData === null) {
@@ -110,14 +56,9 @@ export class Application {
11056
const percent = (100 * doneLength / totalLength).toFixed(0);
11157
console.log(`${source.argv0}: fetched ${percent}% (${doneLength} / ${totalLength})`);
11258
};
113-
const fetchProgress = options.fetchProgress ?? defaultFetchProgress;
114-
return this.#resources()
115-
.then((resourceObject) =>
116-
fetchResources(resourceObject, fetchProgress, this))
117-
.then((resourceData) => {
118-
this.#resourceData = resourceData;
119-
return this.run(args, files, options);
120-
});
59+
return this.#fetchResources(options.fetchProgress ?? defaultFetchProgress).then(() => {
60+
return this.run(args, files, options);
61+
});
12162
}
12263

12364
if (args === null)

package-in.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@yowasp/runtime",
3-
"version": "10.0",
3+
"version": "11.0",
44
"description": "Common runtime for YoWASP packages",
55
"author": "Catherine <[email protected]>",
66
"license": "ISC",

test/yowasp_runtime_test/index.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { Application, Exit } from '@yowasp/runtime';
2+
import * as resources from './gen/resources.js';
23
import { lineBuffered, chunked } from '@yowasp/runtime/util';
34
import { instantiate } from './gen/copy.js';
45

5-
6-
const yowaspRuntimeTest = new Application(() => import('./gen/resources.js'), instantiate, 'copy');
6+
const yowaspRuntimeTest = new Application(resources, instantiate, 'copy');
77

88

99
if ((await yowaspRuntimeTest.run(['share/foo.txt', 'bar.txt'], {}))['bar.txt'] !== 'contents of foo')
10-
throw 'test 1 failed';
10+
throw 'test 1 failed (1)';
11+
if ((await yowaspRuntimeTest.run(['share/bar/baz.txt', 'bar.txt'], {}))['bar.txt'] !== 'meow\n')
12+
throw 'test 1 failed (2)';
1113

1214
if ((await yowaspRuntimeTest.run(['baz.txt', 'bar.txt'], {'baz.txt': 'contents of baz'}))['bar.txt'] !== 'contents of baz')
1315
throw 'test 2 failed';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
meow

0 commit comments

Comments
 (0)