Warm-up · Activity 1 of 7
// B4.5 · ~45 min · Beginner
Build: a parallel folder-size report
You build a command that walks a folder tree, stats files a few at a time and prints the size of each top-level folder, largest first, reporting unreadable entries instead of stopping.
Lesson 5 of 5 in B4 Asynchronous Node
You will be able to
- Walk a tree with readdir and stat, sum sizes per top-level folder, print a sorted report
- Stat files in parallel with a limit, keeping each failure as a result with Promise.allSettled
- Wire it with util.parseArgs: report on stdout, problems on stderr, exit codes 0, 1 and 2
Predict · Activity 2 of 7
Predict before you read on. sample holds docs/a.txt (1200 bytes), docs/guide/b.txt (300 bytes), img/logo.svg (2048 bytes) and notes.txt (10 bytes). What is the first line of the finished report?
node main.js samplePractice · Activity 3 of 7
In sumByTop, slash is the position of the first / in the path. Fill in the end of the slice, so that docs/guide/b.txt counts for the row docs/, with the slash.
const name = slash === -1 ? path : path.slice(0, ____);const name = slash === -1 ? path : path.slice(0, );Practice · Activity 4 of 7
Match each run of the finished tool to what it does.
Practice · Activity 5 of 7
settleLimit is the finished pool from report.js. What does this program print?
const task = async (name) => { if (name === 'b') throw new Error('b is gone'); return name.toUpperCase(); }; const results = await settleLimit(['a', 'b', 'c'], 2, task); console.log(results.map((r) => (r.status === 'fulfilled' ? r.value : r.reason.message)));Brain teaser · Activity 6 of 7
Brain teaser. B4.3's pool, without Promise.allSettled, gets four tasks: a takes 30 ms, bad fails after 10 ms, c and d take 10 ms each. What does the program print?
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // B4.3's pool, without Promise.allSettled. async function mapLimit(items, limit, fn) { const results = []; let next = 0; async function worker() { while (next < items.length) { const index = next++; results[index] = await fn(items[index]); } } await Promise.all(Array.from({length: limit}, worker)); return results; } async function task([name, ms]) { console.log('start', name); await sleep(ms); if (name === 'bad') throw new Error('bad'); } try { await mapLimit([['a', 30], ['bad', 10], ['c', 10], ['d', 10]], 2, task); } catch (error) { console.log('caught', error.message); }Apply · Activity 7 of 7
Mini-task. Add an option --by to run(): --by size, the default, keeps the order of sumByTop; --by name sorts the rows by name. Any other value is wrong usage, with the usage line and exit code 2. Run it on a folder of your own with both values, and check that the total line stays last.
Check your work against this list
Build it yourself
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
The same parts, a different report: the largest files
Before you build the folder report, here are its parts in a smaller program that finds the largest files. It builds a small tree, lists it with readdir and recursive, and then deletes one file, as if another program removed it during the run. A pool of two workers stats the entries; each wraps its stat in Promise.allSettled, so the missing file becomes a result. countedStat counts how many stats run at the same moment. The largest files are sorted by size, then by path.
main.js
import {mkdir, readdir, rm, stat, writeFile} from 'node:fs/promises';
import {join} from 'node:path';
// A small tree for the demo.
await rm('demo', {recursive: true, force: true});
await mkdir('demo/src/lib', {recursive: true});
await writeFile('demo/README.md', '# demo\n');
await writeFile('demo/src/main.js', 'console.log(1);\n'.repeat(20));
await writeFile('demo/src/lib/util.js', 'export {};\n'.repeat(5));
await writeFile('demo/src/old.js', '// soon gone\n');
// Every entry that is not a folder, with its full path.
const entries = await readdir('demo', {recursive: true, withFileTypes: true});
const paths = entries.filter((entry) => !entry.isDirectory()).map((entry) => join(entry.parentPath, entry.name));
await rm('demo/src/old.js'); // deleted after the listing, before its stat
let active = 0; // stats running right now
let most = 0; // the most that ran at the same time
async function countedStat(path) {
active++;
most = Math.max(most, active);
try {
return await stat(path);
} finally {
active--;
}
}
// A pool of two workers; Promise.allSettled turns each stat into a result.
const results = [];
let next = 0;
async function worker() {
while (next < paths.length) {
const index = next++;
const [result] = await Promise.allSettled([countedStat(paths[index])]);
results[index] = result;
}
}
await Promise.all([worker(), worker()]);
const found = [];
results.forEach((result, i) => {
if (result.status === 'fulfilled') found.push({path: paths[i], size: result.value.size});
else console.log('skipped', paths[i] + ':', result.reason.code);
});
found.sort((a, b) => b.size - a.size || (a.path < b.path ? -1 : 1));
for (const {path, size} of found.slice(0, 2)) console.log(size, 'bytes', path);
console.log(paths.length, 'entries listed,', found.length, 'measured, at most', most, 'stats at once');
Run it with
node main.jsOutput
skipped demo/src/old.js: ENOENT
320 bytes demo/src/main.js
55 bytes demo/src/lib/util.js
4 entries listed, 3 measured, at most 2 stats at once- The deleted file is reported once and skipped; the other three are still measured.
- The folders src and lib were listed too, and the filter with isDirectory() dropped them.
- At most 2 stats ran at once, the number of workers, however long the list.
- The sort makes the output the same on every run, whatever order readdir gave.
Exercises
Exercise 1 of 4
Step 1: sizes per top-level folder
Write sumByTop(records). Each record is {path, size}, with / between the parts of the path. Return one row {name, size, files} per top-level entry: docs/guide/b.txt counts for the row docs/ (with the slash), and a path without a slash, such as notes.txt, is a row of its own. size is the sum, files the count. Sort largest first, and equal sizes by name, as < compares strings. It is pure, so it runs in the browser too.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.
Hints
Hint 1
const slash = path.indexOf('/'); is -1 for a file at the top. Otherwise path.slice(0, slash + 1) is the folder name with its slash.
Hint 2
Keep the rows in a Map by name: totals.get(name) ?? {name, size: 0, files: 0}, then add the size and 1 to files.
Hint 3
Sort [...totals.values()] with (a, b) => b.size - a.size || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0).
Show a solution
One way to solve it. Yours can look different and still pass the checks.
// Sums the sizes per top-level entry: a folder such as docs/, or a file at the top.
// Largest first; equal sizes by name, so the report is the same on every run.
export function sumByTop(records) {
const totals = new Map();
for (const {path, size} of records) {
const slash = path.indexOf('/');
const name = slash === -1 ? path : path.slice(0, slash + 1);
const row = totals.get(name) ?? {name, size: 0, files: 0};
row.size += size;
row.files += 1;
totals.set(name, row);
}
return [...totals.values()].sort((a, b) => b.size - a.size || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
}
console.log(sumByTop([
{path: 'docs/a.txt', size: 1200},
{path: 'img/logo.svg', size: 2048},
{path: 'docs/guide/b.txt', size: 300},
{path: 'notes.txt', size: 10}
]));
Run it on your computer
Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
// Sums the sizes per top-level entry: a folder such as docs/, or a file at the top.
// Largest first; equal sizes by name, so the report is the same on every run.
export function sumByTop(records) {
return records.map(({path, size}) => ({name: path, size, files: 1}));
}
console.log(sumByTop([
{path: 'docs/a.txt', size: 1200},
{path: 'img/logo.svg', size: 2048},
{path: 'docs/guide/b.txt', size: 300},
{path: 'notes.txt', size: 10}
]));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {sumByTop} from './main.js';
const records = [
{path: 'docs/a.txt', size: 1200},
{path: 'img/logo.svg', size: 2048},
{path: 'docs/guide/b.txt', size: 300},
{path: 'notes.txt', size: 10}
];
test('files below the same top-level folder are added up, largest row first', () => {
const got = sumByTop(records);
const want = [
{name: 'img/', size: 2048, files: 1},
{name: 'docs/', size: 1500, files: 2},
{name: 'notes.txt', size: 10, files: 1}
];
assert.deepEqual(got, want, `sumByTop returned ${JSON.stringify(got)}`);
});
test('equal sizes are sorted by name', () => {
const got = sumByTop([{path: 'b.txt', size: 5}, {path: 'c/x.txt', size: 9}, {path: 'a/y.txt', size: 5}]).map((row) => row.name);
assert.deepEqual(got, ['c/', 'a/', 'b.txt'], `the rows came in the order ${JSON.stringify(got)}`);
});
test('no records give no rows', () => {
assert.deepEqual(sumByTop([]), [], `sumByTop([]) returned ${JSON.stringify(sumByTop([]))}`);
});
test('the records passed in are not changed', () => {
const copy = records.map((record) => ({...record}));
sumByTop(copy);
assert.deepEqual(copy, records, 'sumByTop changed the records it was given');
});
package.json
{
"type": "module"
}
package.json tells Node.js that the .js files are modules; keep it in the folder.
Run the program:
node main.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsExercise 2 of 4
Step 2: readable sizes and the report
Write formatSize(bytes): below 1024, the number and B, such as 512 B; below 1024 × 1024, KiB with one decimal, such as 1.5 KiB; above that, MiB with one decimal. Then formatReport(rows): one line per row, name, two spaces, the size, two spaces and the count in brackets, (1 file) or (2 files); then a line total with the sums. Every line ends with a newline. Both are pure, so they run in the browser too.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.
Hints
Hint 1
(bytes / 1024).toFixed(1) + ' KiB' gives one decimal; test bytes < 1024 first, then bytes < 1024 * 1024.
Hint 2
Write a helper line(name, size, files) that gives name + ' ' + formatSize(size) + ' (' + files + ' files)', with ' file)' when files is 1.
Hint 3
Add up size and files over all rows with reduce, append line("total", …), join with a newline and add one at the end.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
// 512 B, 1.5 KiB, 2.0 MiB: steps of 1024, one decimal from KiB on.
export function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KiB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MiB';
}
// One line per row, then a total line.
export function formatReport(rows) {
const line = (name, size, files) => name + ' ' + formatSize(size) + ' (' + files + (files === 1 ? ' file)' : ' files)');
const size = rows.reduce((sum, row) => sum + row.size, 0);
const files = rows.reduce((sum, row) => sum + row.files, 0);
return [...rows.map((row) => line(row.name, row.size, row.files)), line('total', size, files)].join('\n') + '\n';
}
console.log(formatReport([
{name: 'img/', size: 2048, files: 1},
{name: 'docs/', size: 1500, files: 2},
{name: 'notes.txt', size: 10, files: 1}
]));
Run it on your computer
Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
// 512 B, 1.5 KiB, 2.0 MiB: steps of 1024, one decimal from KiB on.
export function formatSize(bytes) {
return bytes + ' B';
}
// One line per row, then a total line.
export function formatReport(rows) {
return rows.map((row) => row.name + ' ' + formatSize(row.size)).join('\n');
}
console.log(formatReport([
{name: 'img/', size: 2048, files: 1},
{name: 'docs/', size: 1500, files: 2},
{name: 'notes.txt', size: 10, files: 1}
]));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {formatReport, formatSize} from './main.js';
test('below 1024 bytes: the number and B', () => {
for (const [bytes, want] of [[0, '0 B'], [10, '10 B'], [1023, '1023 B']]) {
assert.equal(formatSize(bytes), want, `formatSize(${bytes}) returned ${JSON.stringify(formatSize(bytes))}`);
}
});
test('KiB and MiB with one decimal', () => {
for (const [bytes, want] of [[1024, '1.0 KiB'], [1536, '1.5 KiB'], [1048576, '1.0 MiB'], [3407872, '3.3 MiB']]) {
assert.equal(formatSize(bytes), want, `formatSize(${bytes}) returned ${JSON.stringify(formatSize(bytes))}`);
}
});
test('one line per row, then the total', () => {
const got = formatReport([{name: 'img/', size: 2048, files: 1}, {name: 'docs/', size: 1500, files: 2}]);
assert.equal(got, 'img/ 2.0 KiB (1 file)\ndocs/ 1.5 KiB (2 files)\ntotal 3.5 KiB (3 files)\n', `formatReport returned ${JSON.stringify(got)}`);
});
test('no rows give only the total', () => {
assert.equal(formatReport([]), 'total 0 B (0 files)\n', `formatReport([]) returned ${JSON.stringify(formatReport([]))}`);
});
package.json
{
"type": "module"
}
package.json tells Node.js that the .js files are modules; keep it in the folder.
Run the program:
node main.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsExercise 3 of 4
Step 3: a pool that settles
settleLimit(items, limit, fn) must call fn(item) for every item with at most limit calls at once, and return one {status, value} or {status, reason} object per item, in the order of items, like Promise.allSettled. A failing call must not stop its worker. The starter settles everything but starts every call at once. Start limit workers (or fewer, for a short list) that take the next index until none are left. fakeStat stands in for stat.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.
Hints
Hint 1
Keep let next = 0; and a results array outside the workers. A worker loops while (next < items.length) and takes const index = next++.
Hint 2
Inside the loop: const [result] = await Promise.allSettled([fn(items[index])]); then results[index] = result. A rejection is now a result, not a throw.
Hint 3
Start Math.min(limit, items.length) workers in an array, await Promise.all(workers), then return results.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
// Calls fn(item) for every item, with at most limit calls running at once.
// Never rejects: one {status, value} or {status, reason} per item, in the
// order of items, as Promise.allSettled gives them.
export async function settleLimit(items, limit, fn) {
const results = [];
let next = 0; // the index of the next item to start
async function worker() {
while (next < items.length) {
const index = next++;
const [result] = await Promise.allSettled([fn(items[index])]);
results[index] = result;
}
}
const workers = [];
for (let i = 0; i < Math.min(limit, items.length); i++) workers.push(worker());
await Promise.all(workers);
return results;
}
// A stand-in for stat: waits, then gives a size, or fails for a file that is gone.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const fakeStat = async (name) => {
await sleep(10);
if (name === 'gone.txt') throw new Error('ENOENT: ' + name);
return {size: name.length};
};
const results = await settleLimit(['a.txt', 'gone.txt', 'bb.txt'], 2, fakeStat);
console.log(results.map((r) => (r.status === 'fulfilled' ? r.value.size : r.reason.message)));
Run it on your computer
Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
// Calls fn(item) for every item, with at most limit calls running at once.
// Never rejects: one {status, value} or {status, reason} per item, in the
// order of items, as Promise.allSettled gives them.
export async function settleLimit(items, limit, fn) {
return Promise.allSettled(items.map(fn)); // starts every call at once
}
// A stand-in for stat: waits, then gives a size, or fails for a file that is gone.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const fakeStat = async (name) => {
await sleep(10);
if (name === 'gone.txt') throw new Error('ENOENT: ' + name);
return {size: name.length};
};
const results = await settleLimit(['a.txt', 'gone.txt', 'bb.txt'], 2, fakeStat);
console.log(results.map((r) => (r.status === 'fulfilled' ? r.value.size : r.reason.message)));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {settleLimit} from './main.js';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// fn for the tests: counts the calls running at once; fails for negative numbers.
function counter() {
const seen = {active: 0, most: 0, calls: 0};
seen.fn = async (ms) => {
seen.calls++;
seen.active++;
seen.most = Math.max(seen.most, seen.active);
await sleep(Math.abs(ms));
seen.active--;
if (ms < 0) throw new Error('failed ' + ms);
return ms * 2;
};
return seen;
}
test('one result per item, in the order of items', async () => {
const got = await settleLimit([30, 10, 20], 2, counter().fn);
const want = [{status: 'fulfilled', value: 60}, {status: 'fulfilled', value: 20}, {status: 'fulfilled', value: 40}];
assert.deepEqual(got, want, `settleLimit returned ${JSON.stringify(got)}`);
});
test('never runs more than limit calls at once', async () => {
const seen = counter();
await settleLimit([20, 10, 30, 10, 20, 10], 2, seen.fn);
assert.equal(seen.most, 2, `at most ${seen.most} calls ran at once; expected 2`);
});
test('a failing call is a rejected result, and every item is still done', async () => {
const seen = counter();
const got = await settleLimit([10, -10, 10, 10], 2, seen.fn);
assert.deepEqual(got.map((r) => r.status), ['fulfilled', 'rejected', 'fulfilled', 'fulfilled'], `the statuses were ${JSON.stringify(got.map((r) => r.status))}`);
assert.equal(got[1].reason.message, 'failed -10', 'the rejected result should keep the error as its reason');
assert.equal(seen.calls, 4, `fn was called ${seen.calls} times; expected 4`);
});
test('an empty list gives an empty array', async () => {
const got = await settleLimit([], 3, counter().fn);
assert.deepEqual(got, [], `settleLimit returned ${JSON.stringify(got)}`);
});
package.json
{
"type": "module"
}
package.json tells Node.js that the .js files are modules; keep it in the folder.
Run the program:
node main.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsExercise 4 of 4
Step 4: walk and measure on disk
report.js holds steps 1 to 3, and main.js has run() and its wiring. Write the file-system part. listFiles(root) lists the whole tree with readdir, recursive and withFileTypes, drops folders, and returns each path relative to root with / between the parts. statAll(root, paths, limit) stats them with settleLimit and returns {records, problems}: {path, size} for each success, and a line cannot read <path>: <code> plus a newline for each failure. Check with node --test.
This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.
Hints
Hint 1
readdir(root, {recursive: true, withFileTypes: true}) gives Dirent objects for the whole tree; keep those where !entry.isDirectory().
Hint 2
relative(root, join(entry.parentPath, entry.name)).split(sep).join('/') gives docs/guide/b.txt on every system.
Hint 3
In statAll: await settleLimit(paths, limit, (path) => stat(join(root, path))), then sort each result by result.status into records or problems.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import {readdir, stat} from 'node:fs/promises';
import {join, relative, sep} from 'node:path';
import {parseArgs} from 'node:util';
import {formatReport, settleLimit, sumByTop} from './report.js';
const USAGE = 'Usage: node main.js [folder] [--limit <n>]';
// Every entry below root that is not a folder, as a path relative to root with / between parts.
async function listFiles(root) {
const entries = await readdir(root, {recursive: true, withFileTypes: true});
return entries
.filter((entry) => !entry.isDirectory())
.map((entry) => relative(root, join(entry.parentPath, entry.name)).split(sep).join('/'));
}
// Stats every path, at most limit at once. A path that fails is a problem, not the end of the run.
async function statAll(root, paths, limit) {
const results = await settleLimit(paths, limit, (path) => stat(join(root, path)));
const records = [];
const problems = [];
results.forEach((result, i) => {
if (result.status === 'fulfilled') records.push({path: paths[i], size: result.value.size});
else problems.push('cannot read ' + paths[i] + ': ' + result.reason.code + '\n');
});
return {records, problems};
}
// What to print, and the exit code: 0 = all read, 1 = something could not be read, 2 = wrong usage.
async function run(args) {
let values, positionals;
try {
({values, positionals} = parseArgs({
args,
options: {limit: {type: 'string', default: '8'}},
allowPositionals: true
}));
} catch (error) {
return {out: '', err: error.message + '\n' + USAGE + '\n', code: 2};
}
const limit = Number(values.limit);
if (positionals.length > 1 || !Number.isInteger(limit) || limit < 1) return {out: '', err: USAGE + '\n', code: 2};
const root = positionals[0] ?? '.';
let paths;
try {
paths = await listFiles(root);
} catch (error) {
return {out: '', err: 'cannot read ' + root + ': ' + error.code + '\n', code: 1};
}
const {records, problems} = await statAll(root, paths, limit);
return {out: formatReport(sumByTop(records)), err: problems.sort().join(''), code: problems.length > 0 ? 1 : 0};
}
// The only lines that touch the real process.
const {out, err, code} = await run(process.argv.slice(2));
process.stdout.write(out);
process.stderr.write(err);
process.exitCode = code;
Run it on your computer
Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
import {readdir, stat} from 'node:fs/promises';
import {join, relative, sep} from 'node:path';
import {parseArgs} from 'node:util';
import {formatReport, settleLimit, sumByTop} from './report.js';
const USAGE = 'Usage: node main.js [folder] [--limit <n>]';
// Every entry below root that is not a folder, as a path relative to root with / between parts.
async function listFiles(root) {
return readdir(root); // only the top level, and folders too
}
// Stats every path, at most limit at once. A path that fails is a problem, not the end of the run.
async function statAll(root, paths, limit) {
const infos = await Promise.all(paths.map((path) => stat(join(root, path))));
return {records: paths.map((path, i) => ({path, size: infos[i].size})), problems: []};
}
// What to print, and the exit code: 0 = all read, 1 = something could not be read, 2 = wrong usage.
async function run(args) {
let values, positionals;
try {
({values, positionals} = parseArgs({
args,
options: {limit: {type: 'string', default: '8'}},
allowPositionals: true
}));
} catch (error) {
return {out: '', err: error.message + '\n' + USAGE + '\n', code: 2};
}
const limit = Number(values.limit);
if (positionals.length > 1 || !Number.isInteger(limit) || limit < 1) return {out: '', err: USAGE + '\n', code: 2};
const root = positionals[0] ?? '.';
let paths;
try {
paths = await listFiles(root);
} catch (error) {
return {out: '', err: 'cannot read ' + root + ': ' + error.code + '\n', code: 1};
}
const {records, problems} = await statAll(root, paths, limit);
return {out: formatReport(sumByTop(records)), err: problems.sort().join(''), code: problems.length > 0 ? 1 : 0};
}
// The only lines that touch the real process.
const {out, err, code} = await run(process.argv.slice(2));
process.stdout.write(out);
process.stderr.write(err);
process.exitCode = code;
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';
test('the sample folder gives one row per top-level entry, largest first', async () => {
const got = await runMain({args: ['sample']});
assert.equal(got, "img/ 2.0 KiB (1 file)\ndocs/ 1.5 KiB (2 files)\nnotes.txt 10 B (1 file)\ntotal 3.5 KiB (4 files)\n", `the report was ${JSON.stringify(got)}`);
});
test('a limit of 1 gives the same report', async () => {
const got = await runMain({args: ['sample', '--limit', '1']});
assert.equal(got, "img/ 2.0 KiB (1 file)\ndocs/ 1.5 KiB (2 files)\nnotes.txt 10 B (1 file)\ntotal 3.5 KiB (4 files)\n", `with --limit 1 the report was ${JSON.stringify(got)}`);
});
test('paths are relative to the folder given', async () => {
const got = await runMain({args: ['sample/docs']});
assert.equal(got, 'a.txt 1.2 KiB (1 file)\nguide/ 300 B (1 file)\ntotal 1.5 KiB (2 files)\n', `the report for sample/docs was ${JSON.stringify(got)}`);
});
test('a folder that does not exist ends with an error code', async () => {
await assert.rejects(runMain({args: ['nowhere']}), Error, 'node main.js nowhere should exit with a code other than 0');
});
test('a limit of 0 is wrong usage', async () => {
await assert.rejects(runMain({args: ['sample', '--limit', '0']}), Error, 'node main.js sample --limit 0 should exit with a code other than 0');
});
report.js
// Pure functions: no fs, no process, so they run anywhere.
// Calls fn(item) for every item, with at most limit calls running at once.
// Never rejects: one {status, value} or {status, reason} per item, in the
// order of items, as Promise.allSettled gives them.
export async function settleLimit(items, limit, fn) {
const results = [];
let next = 0; // the index of the next item to start
async function worker() {
while (next < items.length) {
const index = next++;
const [result] = await Promise.allSettled([fn(items[index])]);
results[index] = result;
}
}
const workers = [];
for (let i = 0; i < Math.min(limit, items.length); i++) workers.push(worker());
await Promise.all(workers);
return results;
}
// Sums the sizes per top-level entry: a folder such as docs/, or a file at the top.
// Largest first; equal sizes by name, so the report is the same on every run.
export function sumByTop(records) {
const totals = new Map();
for (const {path, size} of records) {
const slash = path.indexOf('/');
const name = slash === -1 ? path : path.slice(0, slash + 1);
const row = totals.get(name) ?? {name, size: 0, files: 0};
row.size += size;
row.files += 1;
totals.set(name, row);
}
return [...totals.values()].sort((a, b) => b.size - a.size || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
}
// 512 B, 1.5 KiB, 2.0 MiB: steps of 1024, one decimal from KiB on.
export function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KiB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MiB';
}
// One line per row, then a total line.
export function formatReport(rows) {
const line = (name, size, files) => name + ' ' + formatSize(size) + ' (' + files + (files === 1 ? ' file)' : ' files)');
const size = rows.reduce((sum, row) => sum + row.size, 0);
const files = rows.reduce((sum, row) => sum + row.files, 0);
return [...rows.map((row) => line(row.name, row.size, row.files)), line('total', size, files)].join('\n') + '\n';
}
sample/docs/a.txt
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
sample/docs/guide/b.txt
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
sample/img/logo.svg
ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
sample/notes.txt
ten bytes
package.json
{
"type": "module"
}
package.json tells Node.js that the .js files are modules; keep it in the folder.
Run the program:
node main.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsCommon mistakes
Forgetting parentPath in a recursive listing
import {mkdir, readdir, stat, writeFile} from 'node:fs/promises';
import {join} from 'node:path';
await mkdir('sample/docs', {recursive: true});
await writeFile('sample/docs/a.txt', 'alpha\n');
for (const entry of await readdir('sample', {recursive: true, withFileTypes: true})) {
if (entry.isFile()) console.log(entry.name, (await stat(join('sample', entry.name))).size);
}
What Node.js prints
Error: ENOENT: no such file or directory, stat 'sample/a.txt'Why, and the fix
With recursive, entry.name is only the last part, a.txt, while the file sits in sample/docs. join("sample", entry.name) points at a file that does not exist. entry.parentPath is the folder the entry was found in, so join(entry.parentPath, entry.name) is the full path: sample/docs/a.txt.
Reading value from a rejected result
const results = await Promise.allSettled([Promise.resolve(1), Promise.reject(new Error('gone'))]);
console.log(results.map((r) => r.status));
const failed = results.filter((r) => r.status === 'rejected');
console.log(failed[0].value.code);
What Node.js prints
TypeError: Cannot read properties of undefined (reading 'code')Why, and the fix
A fulfilled result has value; a rejected one has reason instead, and value is undefined. Check result.status first, then read result.value for 'fulfilled' and result.reason for 'rejected', here failed[0].reason.message.
Using Dirent methods without withFileTypes
import {mkdir, readdir, writeFile} from 'node:fs/promises';
await mkdir('sample/docs', {recursive: true});
await writeFile('sample/docs/a.txt', 'alpha\n');
const entries = await readdir('sample', {recursive: true});
const files = entries.filter((entry) => !entry.isDirectory());
console.log(files);
What Node.js prints
TypeError: entry.isDirectory is not a functionWhy, and the fix
Without withFileTypes, readdir gives plain strings, such as docs/a.txt, and a string has no isDirectory method. Pass {recursive: true, withFileTypes: true} to get Dirent objects, which know their type, their name and their parentPath.
JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source
Exit ticket
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.