Skip to content

Commit 66bef0b

Browse files
committed
playground: load grep and find on demand
The standalone WASM modules (grep ~1.4 MB, find ~2.0 MB) were fetched eagerly at startup, adding ~3.4 MB (~22%) to the initial download for visitors who never use them. Load them on demand instead: the first time a command runs it auto-loads its module (with a live 'loading… done' notice), and a row of 'Load' buttons lets visitors pre-warm them. A shared in-flight map dedupes a click and a command racing to load the same module, and buttons stay in sync via a 'uutils:program-loaded' event whether the module was loaded by click or by use.
1 parent 81f75ae commit 66bef0b

4 files changed

Lines changed: 169 additions & 26 deletions

File tree

content/playground.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ template = "page.html"
1313
<span class="locale-help">Missing a translation or your language? <a href="https://hosted.weblate.org/projects/rust-coreutils/">Help translate on Weblate</a></span>
1414
</div>
1515

16+
<div class="playground-loaders" id="playground-loaders">
17+
<span class="playground-loaders-label">Extra programs:</span>
18+
</div>
19+
1620
<div id="wasm-playground"></div>
1721

1822
<p class="playground-version" id="playground-version"></p>
@@ -24,6 +28,47 @@ template = "page.html"
2428
<script defer>
2529
document.addEventListener("DOMContentLoaded", function() {
2630
initPlayground("wasm-playground");
31+
// Build a "Load" button per optional standalone program (grep, find).
32+
// These ship as their own WASM modules and load on demand to keep the
33+
// initial page download light; running the command auto-loads it too.
34+
var loaderBar = document.getElementById("playground-loaders");
35+
if (loaderBar && Array.isArray(window.uutilsPrograms)) {
36+
window.uutilsPrograms.forEach(function(cmd) {
37+
var btn = document.createElement("button");
38+
btn.className = "playground-loader";
39+
var markLoaded = function() {
40+
btn.disabled = true;
41+
btn.classList.add("loaded");
42+
btn.textContent = "" + cmd + " loaded";
43+
};
44+
var setIdleLabel = function(size) {
45+
btn.textContent = "Load " + cmd + (size ? " (" + (size / 1024 / 1024).toFixed(1) + " MB)" : "");
46+
};
47+
setIdleLabel(0);
48+
window.programSize(cmd).then(function(size) {
49+
if (!btn.classList.contains("loaded") && !btn.disabled) setIdleLabel(size);
50+
});
51+
btn.addEventListener("click", function() {
52+
if (btn.disabled) return;
53+
btn.disabled = true;
54+
btn.textContent = "Loading " + cmd + "";
55+
window.loadProgram(cmd).then(function(mod) {
56+
if (mod) {
57+
markLoaded();
58+
} else {
59+
btn.disabled = false;
60+
btn.textContent = cmd + " unavailable";
61+
}
62+
});
63+
});
64+
// Keep the button in sync if the program is loaded by running it.
65+
document.addEventListener("uutils:program-loaded", function(e) {
66+
if (e.detail && e.detail.cmd === cmd) markLoaded();
67+
});
68+
if (window.isProgramLoaded(cmd)) markLoaded();
69+
loaderBar.appendChild(btn);
70+
});
71+
}
2772
// Populate the locale dropdown from the build-generated list
2873
if (typeof WASM_LOCALES !== "undefined") {
2974
var sel = document.getElementById("locale-select");

static/js/wasm-terminal.js

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ let wasmModule = null;
8080
// Compiled standalone modules, keyed by command name (e.g. "grep", "find").
8181
// A key is present only once its module has loaded successfully.
8282
const standaloneModules = {};
83+
// In-flight loads, keyed by command name, so a button click and a command that
84+
// both trigger a load (or two rapid clicks) share one fetch instead of racing.
85+
const standaloneLoading = {};
8386
let wasiShim = null;
8487
let terminal = null;
8588
let inputBuffer = "";
@@ -167,32 +170,54 @@ async function loadWasm() {
167170
}
168171

169172
/**
170-
* Load the optional standalone WASM modules (grep, find, …). Each is optional:
171-
* if a binary isn't present (e.g. local dev without a CI build), its error is
172-
* swallowed and the corresponding command reports that it's unavailable rather
173-
* than breaking the terminal or blocking the coreutils module.
173+
* Load one optional standalone module (grep, find, …) on demand. These are not
174+
* part of the eager startup download — they're fetched the first time the user
175+
* runs the command or clicks its "Load" button, keeping the initial page load
176+
* to just the coreutils multicall binary.
177+
*
178+
* Concurrent callers share a single in-flight fetch. Returns the compiled
179+
* module, or null if it's unknown or its binary isn't present (e.g. local dev
180+
* without a CI build) — in which case the command reports it's unavailable
181+
* rather than breaking the terminal.
182+
*
183+
* When `announce` is set and a terminal exists, a "loading <cmd>… done" notice
184+
* is printed live; the button UI instead reacts to the `uutils:program-loaded`
185+
* event dispatched on success.
174186
*/
175-
async function loadStandaloneWasm() {
176-
await Promise.all(
177-
Object.entries(STANDALONE_WASM_URLS).map(async ([cmd, url]) => {
178-
if (standaloneModules[cmd]) return;
179-
try {
180-
const { module, size } = await compileWasmModule(url);
181-
standaloneModules[cmd] = module;
182-
wasmSize += size;
183-
} catch (e) {
184-
console.warn(`${cmd} WASM unavailable:`, e.message);
187+
function loadStandalone(cmd, { announce = false } = {}) {
188+
if (standaloneModules[cmd]) return Promise.resolve(standaloneModules[cmd]);
189+
if (standaloneLoading[cmd]) return standaloneLoading[cmd];
190+
const url = STANDALONE_WASM_URLS[cmd];
191+
if (!url) return Promise.resolve(null);
192+
const notify = announce && terminal;
193+
if (notify) terminal.write(`loading ${cmd}… `);
194+
standaloneLoading[cmd] = (async () => {
195+
try {
196+
const { module, size } = await compileWasmModule(url);
197+
standaloneModules[cmd] = module;
198+
wasmSize += size;
199+
if (notify) terminal.write(`done (${(size / 1024 / 1024).toFixed(1)} MB)\r\n`);
200+
if (typeof document !== "undefined") {
201+
document.dispatchEvent(new CustomEvent("uutils:program-loaded", { detail: { cmd, size } }));
185202
}
186-
})
187-
);
203+
return module;
204+
} catch (e) {
205+
console.warn(`${cmd} WASM unavailable:`, e.message);
206+
if (notify) terminal.write("unavailable\r\n");
207+
return null;
208+
} finally {
209+
delete standaloneLoading[cmd];
210+
}
211+
})();
212+
return standaloneLoading[cmd];
188213
}
189214

190215
async function initWasm() {
191216
if (wasmReady) return;
192217
try {
193-
// The standalone modules are optional and loadStandaloneWasm swallows its
194-
// own errors, so they never block the coreutils module from becoming ready.
195-
await Promise.all([loadWasiShim(), loadWasm(), loadStandaloneWasm()]);
218+
// Only the coreutils multicall binary loads eagerly; the standalone
219+
// modules (grep, find) are fetched on demand — see loadStandalone.
220+
await Promise.all([loadWasiShim(), loadWasm()]);
196221
wasmReady = true;
197222
} catch (e) {
198223
// Will fall back to JS implementations
@@ -583,10 +608,12 @@ async function executeCommandLine(line) {
583608
}
584609

585610
// Some utilities (grep, find) are separate WASM modules rather than part
586-
// of the coreutils multicall binary.
611+
// of the coreutils multicall binary, and are loaded on demand. Fetch the
612+
// module the first time the command is used (no-op once cached).
587613
const isStandalone = cmd in STANDALONE_WASM_URLS;
588614
if (isStandalone && !standaloneModules[cmd]) {
589-
return `${cmd} is not available in this build.\n`;
615+
const mod = await loadStandalone(cmd, { announce: true });
616+
if (!mod) return `${cmd} is not available in this build.\n`;
590617
}
591618

592619
try {
@@ -887,6 +914,7 @@ async function initPlayground(containerId) {
887914
terminal.writeln("");
888915
terminal.writeln("Type \x1b[1;32mhelp\x1b[0m for available commands.");
889916
terminal.writeln("Sample data files: names.txt, numbers.txt, fruits.txt, csv.txt, words.txt");
917+
terminal.writeln("\x1b[2mgrep and find load on demand — just run them, or use the buttons below.\x1b[0m");
890918
} catch (e) {
891919
terminal.writeln(" \x1b[1;31mfailed\x1b[0m");
892920
terminal.writeln("Failed to load WASM binary. Commands are not available.");
@@ -934,6 +962,25 @@ window.uutilsExecute = executeCommandLine;
934962
window.runInTerminal = runInTerminal;
935963
window.setLocale = setLocale;
936964

965+
// On-demand loading of the optional standalone programs (grep, find), used by
966+
// the "Load" buttons on the playground page.
967+
window.uutilsPrograms = Object.keys(STANDALONE_WASM_URLS);
968+
window.loadProgram = (cmd) => loadStandalone(cmd);
969+
window.isProgramLoaded = (cmd) => !!standaloneModules[cmd];
970+
// Best-effort byte size of a program's module, for the button label (0 if the
971+
// server doesn't report Content-Length or the binary is missing).
972+
window.programSize = async (cmd) => {
973+
const url = STANDALONE_WASM_URLS[cmd];
974+
if (!url) return 0;
975+
try {
976+
const r = await fetch(url, { method: "HEAD" });
977+
const cl = r.ok ? r.headers.get("content-length") : null;
978+
return cl ? parseInt(cl, 10) : 0;
979+
} catch {
980+
return 0;
981+
}
982+
};
983+
937984
// Expose internals for testing
938985
window._uutilsTestInternals = {
939986
parseCommandLine,
@@ -951,6 +998,7 @@ window._uutilsTestInternals = {
951998
get grepReady() { return !!standaloneModules.grep; },
952999
get findReady() { return !!standaloneModules.find; },
9531000
initWasm,
1001+
loadStandalone,
9541002
LOCALE_SHORTCUTS,
9551003
SAMPLE_FILES,
9561004
AVAILABLE_COMMANDS,

static/js/wasm-terminal.test.html

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,10 @@ <h1>wasm-terminal unit tests</h1>
450450
"w\nx\n");
451451
}
452452

453-
// ===== grep (standalone WASM module) =====
453+
// ===== grep (standalone WASM module, loaded on demand) =====
454+
// grep is no longer eager-loaded; pull it in via the on-demand loader,
455+
// which also exercises that path. Skips gracefully if grep.wasm is absent.
456+
await T.loadStandalone("grep");
454457
if (T.grepReady) {
455458
section("grep WASM module");
456459

@@ -472,16 +475,21 @@ <h1>wasm-terminal unit tests</h1>
472475
section("grep WASM module (SKIPPED - grep.wasm not loaded)");
473476
}
474477

475-
// ===== find (standalone WASM module) =====
478+
// ===== find (standalone WASM module, auto-loaded on first use) =====
479+
// Unlike grep above, exercise the hybrid auto-load path: the first find
480+
// command should fetch find.wasm on demand. The probe result doubles as the
481+
// "find by exact name" assertion. Skips gracefully if find.wasm is absent.
482+
const findProbe = await executeCommandLine("find . -name names.txt");
476483
if (T.findReady) {
477484
section("find WASM module");
478485

486+
assert("find auto-loaded on first use", T.findReady, true);
487+
479488
assert("find in AVAILABLE_COMMANDS",
480489
AVAILABLE_COMMANDS.includes("find"), true);
481490

482-
await assertAsync("find by exact name",
483-
executeCommandLine("find . -name names.txt"),
484-
"./names.txt\n");
491+
assert("find by exact name (via auto-load)",
492+
findProbe, "./names.txt\n");
485493

486494
await assertAsync("find with no matches",
487495
executeCommandLine("find . -name '*.csv'"),

static/style.css

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,48 @@ main img {
539539
cursor: pointer;
540540
}
541541

542+
.playground-loaders {
543+
display: flex;
544+
align-items: center;
545+
flex-wrap: wrap;
546+
gap: 0.5em;
547+
margin: 0.6em 0 0;
548+
font-size: 0.9em;
549+
}
550+
551+
.playground-loaders-label {
552+
opacity: 0.8;
553+
}
554+
555+
.playground-loader {
556+
padding: 0.3em 0.7em;
557+
background: var(--highlight-bg-color);
558+
color: var(--highlight-fg-color);
559+
border: 1px solid var(--header-border-color);
560+
border-radius: 0.3em;
561+
font-family: "Fira Code", monospace;
562+
font-size: 0.85em;
563+
cursor: pointer;
564+
transition: border-color 0.2s, background-color 0.2s, opacity 0.2s;
565+
}
566+
567+
.playground-loader:hover:not(:disabled) {
568+
border-color: var(--accent-color);
569+
background: var(--accent-color);
570+
color: white;
571+
}
572+
573+
.playground-loader:disabled {
574+
cursor: default;
575+
}
576+
577+
.playground-loader.loaded {
578+
border-color: #2ea043;
579+
background: #2ea043;
580+
color: white;
581+
opacity: 0.9;
582+
}
583+
542584
#wasm-playground {
543585
width: 100%;
544586
min-height: 480px;

0 commit comments

Comments
 (0)