Skip to content

Commit a544787

Browse files
committed
Add a way to monitor download progress.
1 parent 1ecbbdf commit a544787

File tree

4 files changed

+70
-14
lines changed

4 files changed

+70
-14
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Application API reference
1313
All of the other JavaScript YoWASP packages use the common runtime functionality implemented here. They export the function `runX` where `X` is the name of the application, which can be called as:
1414

1515
```js
16-
const filesOut = await runX(args, filesIn, { stdout, stderr, decodeASCII: true });
16+
const filesOut = await runX(args, filesIn, { stdout, stderr, decodeASCII: true, fetchProgress });
1717
```
1818

1919
Arguments and return value:
@@ -24,6 +24,7 @@ Arguments and return value:
2424
Options:
2525
- The `stdout` and `stderr` options are functions that are called with a sequence of bytes the application prints to the standard output and standard error streams respectively, or `null` to indicate that the stream is being flushed. If specified as `null`, the output on that stream is ignored. By default, each line of text from the combined streams is printed to the debugging console.
2626
- The `decodeASCII` option determines whether the values corresponding to files in `filesOut` are always instances of [Uint8Array][] (if `decodeASCII: false`), or whether the values corresponding to text files will be strings (if `decodeASCII: true`). A file is considered a text file if it contains only bytes `0x09`, `0x0a`, `0x0d`, or those in the range `0x20` to `0x7e` inclusive. The default is `decodeASCII: true`.
27+
- The `fetchProgress({ source, totalLength, doneLength })` option provides a callback to monitor download progress. The `source` is the `Application` object whose assets are being downloaded; `totalLength` and `doneLength` are in bytes. Note that if the server does not send the `Content-Length` header for *any* of the assets, `totalLength` will be `NaN`. By default, download progress is printed to the debugging console.
2728

2829
If the application returns a non-zero exit code, the exception `Exit` (exported alongside the `runX` function) is raised. This exception has two properties:
2930
- The `code` property indicates the exit code. (Currently this is always 1 due to WebAssembly peculiarities.)

lib/api.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,21 @@ export type InputStream =
88
export type OutputStream =
99
(bytes: Uint8Array | null) => void;
1010

11+
export type ProgressCallback =
12+
({ source: Application, totalLength: number, doneLength: number }) => void;
13+
1114
export type RunOptions = {
1215
stdin?: InputStream | null;
1316
stdout?: OutputStream | null;
1417
stderr?: OutputStream | null;
1518
decodeASCII?: boolean;
1619
synchronously?: boolean;
20+
fetchProgress?: ProgressCallback;
1721
};
1822

1923
export class Application {
24+
argv0: string;
25+
2026
constructor(resources: () => Promise<any>, instantiate: any, argv0: string);
2127

2228
run(args?: string[], files?: Tree, options?: RunOptions): Promise<Tree> | Tree | undefined;

lib/api.js

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,58 @@ async function fetchObject(obj, fetchFn) {
2424
return obj;
2525
}
2626

27-
function fetchWebAssembly(url) {
27+
function fetchWebAssembly(url, monitorProgress) {
28+
const response = fetch(url).then(monitorProgress);
2829
if (WebAssembly.compileStreaming !== undefined) {
29-
return fetch(url).then(WebAssembly.compileStreaming);
30+
return response.then(WebAssembly.compileStreaming);
3031
} else {
3132
// Node doesn't have `{compile,instantiate}Streaming`.
32-
return fetch(url).then((resp) => resp.arrayBuffer()).then(WebAssembly.compile);
33+
return response.then((resp) => resp.arrayBuffer()).then(WebAssembly.compile);
3334
}
3435
}
3536

36-
function fetchUint8Array(url) {
37-
return fetch(url).then((resp) => resp.arrayBuffer()).then((buf) => new Uint8Array(buf));
37+
function fetchUint8Array(url, monitorProgress) {
38+
const response = fetch(url).then(monitorProgress);
39+
return response.then((resp) => resp.arrayBuffer()).then((buf) => new Uint8Array(buf));
3840
}
3941

40-
function fetchResources({ modules, filesystem }) {
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+
}
4176
return Promise.all([
42-
fetchObject(modules, fetchWebAssembly),
43-
fetchObject(filesystem, fetchUint8Array)
77+
fetchObject(modules, (url) => fetchWebAssembly(url, monitorProgress)),
78+
fetchObject(filesystem, (url) => fetchUint8Array(url, monitorProgress))
4479
]).then(([modules, filesystem]) => {
4580
return { modules, filesystem };
4681
});
@@ -59,15 +94,26 @@ export class Application {
5994
this.#argv0 = argv0;
6095
}
6196

97+
get argv0() {
98+
return this.#argv0;
99+
}
100+
62101
// The `printLine` option is deprecated and not documented but still accepted for compatibility.
63102
run(args = null, files = {}, options = {}) {
64103
if (this.#resourceData === null) {
65104
if (options.synchronously)
66105
throw new Error("Cannot run application synchronously unless resources are " +
67106
"prefetched first; use `await run()` to do so");
68107

108+
/** @type {ProgressCallback} */
109+
const defaultFetchProgress = ({ source, totalLength, doneLength }) => {
110+
const percent = (100 * doneLength / totalLength).toFixed(0);
111+
console.log(`${source.argv0}: fetched ${percent}% (${doneLength} / ${totalLength})`);
112+
};
113+
const fetchProgress = options.fetchProgress ?? defaultFetchProgress;
69114
return this.#resources()
70-
.then((resourceObject) => fetchResources(resourceObject))
115+
.then((resourceObject) =>
116+
fetchResources(resourceObject, fetchProgress, this))
71117
.then((resourceData) => {
72118
this.#resourceData = resourceData;
73119
return this.run(args, files, options);

lib/fetch.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ if (typeof process === 'object' && process.release?.name === 'node') {
44
fetch = async function(url, options) {
55
if (url.protocol === 'file:') {
66
const { readFile } = await import('node:fs/promises');
7-
let contentType = 'application/octet-stream';
8-
if (url.pathname.endsWith('.wasm'))
9-
contentType = 'application/wasm';
10-
return new Response(await readFile(url), { headers: { 'Content-Type': contentType } });
7+
const data = await readFile(url);
8+
const isWasm = url.pathname.endsWith('.wasm');
9+
const headers = {
10+
'content-length': data.length,
11+
'content-type': (isWasm ? 'application/wasm' : 'application/octet-stream'),
12+
};
13+
return new Response(data, { headers });
1114
} else {
1215
return globalThis.fetch(url, options);
1316
}

0 commit comments

Comments
 (0)