// Projekt Aufbau · etwa 5 Stunden Arbeit
Leseliste
Sie bauen eine Leseliste aus zwei Hälften, die sich ein Modul teilen. Auf der Kommandozeile halten node main.js add Dune --author "Frank Herbert", read 1 und list --unread Ihre Bücher in einer JSON-Datei. Gegen einen kleinen lokalen JSON-Server (vorgegeben) fügt client.js mit fetch Bücher hinzu und markiert sie, und eine Listenansicht sagt immer, was gerade passiert: Loading…, die Liste, ein Serverproblem oder eine Anfrage, die zu lange dauerte oder ersetzt wurde. Eine Testsuite für node --test zeigt Ihnen, wann jeder Teil funktioniert.
Was das fertige Programm kann
- books.js exportiert BookError, eine Unterklasse von Error mit dem Namen BookError und einer Eigenschaft field, und ReadingList, eine Klasse, die ihre Bücher privat hält.
- new ReadingList(books) beginnt mit einer Kopie eines Arrays von Büchern {id, title, author, read}. add(title, author) entfernt Leerzeichen am Rand, gibt dem Buch die nächste id (eins mehr als die bisher höchste), speichert es ungelesen und gibt eine Kopie davon zurück.
- add wirft einen BookError für das Feld title mit der Meldung title is required, wenn der Titel leer ist oder nur aus Leerzeichen besteht; markRead(id) wirft für eine unbekannte id einen BookError für das Feld id mit der Meldung no book with id <id>.
- markRead(id) setzt read auf true und gibt eine Kopie des Buchs zurück; list() gibt Kopien aller Bücher in der Reihenfolge des Hinzufügens zurück, list({unread: true}) nur die ungelesenen; JSON.stringify(list) schreibt das Array der Bücher.
- store.js: loadList(file) liest die JSON-Datei in eine ReadingList und gibt eine leere zurück, wenn die Datei nicht existiert (ENOENT); jeder andere Fehler wird weitergeworfen. saveList(file, list) schreibt die Bücher als eingerücktes JSON.
- main.js liest add <Titelwörter> [--author <Name>], read <id> und list [--unread] mit parseArgs; --file <Pfad> wählt die Datendatei (Standard reading-list.json). Es gibt Added <Buch>, Read <Buch> oder eine Zeile pro Buch in der Form [x] 1. Dune by Frank Herbert aus, oder No books yet.
- Ein BookError oder eine unbekannte Option wird als reading-list: <Meldung> auf stderr ausgegeben, ohne Ausgabe auf stdout und mit Exit-Code 1; ein unbekannter Befehl gibt die Usage-Zeile auf stderr aus, mit Exit-Code 2.
- client.js: request(url, {method, body, signal}) sendet body als JSON mit Content-Type: application/json, gibt die geparste Antwort zurück und wirft bei einem Status, der nicht ok ist, einen ApiError (Name ApiError, eine Eigenschaft status, der Fehlertext des Servers als message). addBook(api, title, author) und markRead(api, id) nutzen es.
- createListView(api, render, timeoutMs = 1000) gibt load(path = "/books") zurück: Es rendert Loading…, dann die Bücher (eins pro Zeile) oder No books yet.; bei einem ApiError mit Status 500 oder mehr rendert es The server has a problem. Please try again later.; nach timeoutMs rendert es The server is too slow. Please try again.
- Jedes load bricht das load davor ab, das danach nichts mehr rendert, sodass eine ältere Antwort nie eine neuere ersetzt. Jeder andere Fehler rendert Could not load the list: <Meldung>.
Aufbau der Startdateien
- books.js
- Ihre Arbeit: BookError, die Klasse ReadingList und formatBook (vorgegeben). Keine Dateien, kein Netzwerk, daher leicht zu testen.
- store.js
- Ihre Arbeit: loadList und saveList, die JSON-Datei zwischen den Läufen.
- main.js
- Ihre Arbeit: die Kommandozeile mit parseArgs, aufbauend auf books.js und store.js.
- client.js
- Ihre Arbeit: ApiError, request und createListView, der fetch-Client. addBook und markRead sind vorgegeben.
- server.js
- Der lokale JSON-Server für den Client (node:http, Port 0 auf 127.0.0.1): GET und POST /books, PATCH /books/<id>, /broken (500) und /slow (300 ms). Vollständig vorgegeben.
- main.test.js
- Die Abnahmetests (node:test). Führen Sie sie mit node --test aus.
- package.json
- {"type": "module"}, damit .js-Dateien ES-Module sind. Im Starter-Download enthalten.
- learnrun.js
- Das Hilfsmodul des Kurses zum Ausführen von main.js; die Tests dieses Projekts brauchen es nicht. Im Starter-Download enthalten.
Etappen
Etappe 1
Die Liste und ihre Fehler
Schreiben Sie BookError und ReadingList in books.js: ein privates Array, add mit der nächsten id, markRead, list mit der Option unread und ein BookError mit seinem Feld für falsche Eingaben.
Prüfungen, die nach dieser Etappe bestehen:
- add speichert ein Buch mit der nächsten id
- list gibt alle Bücher zurück oder nur die ungelesenen
- markRead markiert ein Buch als gelesen
- Ungültige Eingaben werfen einen BookError mit seinem Feld
Etappe 2
In einer Datei behalten
Schreiben Sie loadList und saveList in store.js; beim ersten Lauf ist eine fehlende Datei eine leere Liste.
Prüfungen, die nach dieser Etappe bestehen:
- Die Liste übersteht Speichern und Laden, und eine fehlende Datei ist eine leere Liste
Etappe 3
Die Kommandozeile
Lesen Sie add, read und list mit ihren Optionen in main.js, speichern Sie nach jeder Änderung und melden Sie einen BookError auf stderr mit Exit-Code 1.
Prüfungen, die nach dieser Etappe bestehen:
- node main.js fügt Bücher hinzu, markiert und listet sie
- node main.js meldet eine falsche id auf stderr mit Exit-Code 1
Etappe 4
Mit dem Server sprechen
Schreiben Sie ApiError und request in client.js: ein JSON-Body mit seinem Header und ein ApiError für einen Status, der nicht ok ist.
Prüfungen, die nach dieser Etappe bestehen:
- Der Client sendet JSON und macht aus einem Fehlerstatus einen ApiError
Etappe 5
Jeden Zustand zeigen
Schreiben Sie createListView: Loading…, die Liste oder No books yet. und die Meldung für einen 5xx-Status.
Prüfungen, die nach dieser Etappe bestehen:
- Der Client zeigt Loading… und dann die Liste
- Der Client behandelt eine Antwort mit Status 500
Etappe 6
Abbrechen und aufgeben
Kombinieren Sie mit AbortSignal.any einen Controller pro load mit AbortSignal.timeout; ignorieren Sie den AbortError eines ersetzten load und rendern Sie die Meldung für einen TimeoutError.
Prüfungen, die nach dieser Etappe bestehen:
- Der Client bricht eine langsame Anfrage ab
Dieses Projekt nutzt Teile von Node.js, die nicht im Browser laufen, deshalb bauen Sie es auf Ihrem Computer.
Auf dem eigenen Rechner bauen
Legen Sie einen Ordner mit diesen Startdateien an, installieren Sie ECMAScript 2026 und arbeiten Sie die Etappen ab. Die Abnahmetests starten Sie jederzeit mit:
Startdateien als eine .zip herunterladen (Startdateien, main.test.js, package.json und learnrun.js)node --testpackage.json sagt Node.js, dass die .js-Dateien Module sind; lassen Sie die Datei im Ordner.
learnrun.js herunterladenmain.js
// The command line: node main.js add <title> [--author <name>] | read <id> | list [--unread]
import {parseArgs} from "node:util";
import {BookError, formatBook} from "./books.js";
import {loadList, saveList} from "./store.js";
const USAGE = "Usage: node main.js add <title> [--author <name>] | read <id> | list [--unread] (--file <path>)";
// TODO: parse --file, --author and --unread with parseArgs, run the command,
// print BookErrors as "reading-list: <message>" on stderr with exit code 1.
console.error(USAGE);
process.exitCode = 2;
books.js
// The reading list itself: pure logic, no files, no network.
// A typed error for bad input: field names what was wrong ("title" or "id").
export class BookError extends Error {
// TODO: constructor(field, message): message, name "BookError" and field
}
export class ReadingList {
constructor(books = []) {
// TODO: keep a copy of the books
}
add(title, author = "") {
// TODO: refuse an empty title, give the next id, return the new book
throw new Error("add is not written yet");
}
markRead(id) {
// TODO: set read to true, or throw a BookError for an unknown id
throw new Error("markRead is not written yet");
}
list({unread = false} = {}) {
// TODO: every book, or only the unread ones
return [];
}
toJSON() {
return this.list();
}
}
export const formatBook = (book) =>
`${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;
store.js
// Keeps the list in a JSON file between runs.
import {readFile, writeFile} from "node:fs/promises";
import {ReadingList} from "./books.js";
export async function loadList(file) {
// TODO: read and parse the file; a missing file (ENOENT) is an empty list
throw new Error("loadList is not written yet");
}
export async function saveList(file, list) {
// TODO: write the list as JSON
throw new Error("saveList is not written yet");
}
client.js
// The fetch client for the reading-list server.
import {formatBook} from "./books.js";
export class ApiError extends Error {
// TODO: constructor(status, message): message, name "ApiError" and status
}
// Sends a request and returns the JSON answer; a status that is not ok becomes an ApiError.
export async function request(url, {method = "GET", body, signal} = {}) {
// TODO
throw new Error("request is not written yet");
}
export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});
export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});
// Loads the list and tells render what to show. A new load cancels the one before it.
export function createListView(api, render, timeoutMs = 1000) {
return async function load(path = "/books") {
// TODO: Loading…, the list or No books yet., a message for a 5xx and for a timeout,
// and cancel the load before this one.
};
}
server.js
// A small local JSON server for the reading list, made with node:http. You do not need to change it.
import {createServer} from "node:http";
export async function startServer(books = []) {
let nextId = books.length + 1;
const server = createServer(async (request, response) => {
const json = (status, value) => {
response.writeHead(status, {"Content-Type": "application/json"});
response.end(JSON.stringify(value));
};
let body = "";
for await (const chunk of request) body += chunk;
const {method, url} = request;
const match = url.match(/^\/books\/(\d+)$/);
if (method === "GET" && url === "/books") json(200, books);
else if (method === "POST" && url === "/books") {
const {title, author} = JSON.parse(body || "{}");
if (typeof title !== "string" || title.trim() === "") return json(400, {error: "title is required"});
const book = {id: nextId++, title: title.trim(), author: author ?? "", read: false};
books.push(book);
json(201, book);
} else if (method === "PATCH" && match) {
const book = books.find((b) => b.id === Number(match[1]));
if (!book) return json(404, {error: "no such book"});
book.read = JSON.parse(body || "{}").read === true;
json(200, book);
} else if (url === "/broken") json(500, {error: "database is down"});
else if (url === "/slow") {
// answers after 300 ms, unless the client gave up
const timer = setTimeout(() => json(200, books), 300);
response.on("close", () => clearTimeout(timer));
} else json(404, {error: "not found"});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
return {
url: `http://127.0.0.1:${server.address().port}`,
close: () => new Promise((resolve) => server.close(resolve))
};
}
package.json
{
"type": "module"
}
Abnahmetests
Das Projekt ist fertig, wenn jede Prüfung in main.test.js besteht. Lesen Sie sie vor dem Start: Sie sind die Spezifikation, als Code geschrieben.
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {spawnSync} from 'node:child_process';
import {mkdtempSync, readFileSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {ReadingList, BookError} from './books.js';
import {loadList, saveList} from './store.js';
import {request, addBook, markRead, createListView, ApiError} from './client.js';
import {startServer} from './server.js';
// A fresh data file per test, in its own temporary folder.
const dataFile = () => join(mkdtempSync(join(tmpdir(), 'reading-list-')), 'list.json');
const cli = (...args) => spawnSync(process.execPath, ['main.js', ...args], {encoding: 'utf8'});
const DUNE = {id: 1, title: 'Dune', author: 'Frank Herbert', read: false};
/** The error that fn throws (or its promise rejects with); the test fails when there is none. */
async function caught(fn) {
try {
await fn();
} catch (error) {
return error;
}
assert.fail('es wurde nichts geworfen');
}
/** Starts the server with books, gives its address to body, and always closes it. */
async function withServer(books, body) {
const server = await startServer(books);
try {
await body(server.url);
} finally {
await server.close();
}
}
test('add speichert ein Buch mit der nächsten id', () => {
const list = new ReadingList([DUNE]);
const book = list.add(' Emma ', 'Jane Austen');
assert.deepEqual(book, {id: 2, title: 'Emma', author: 'Jane Austen', read: false});
assert.equal(list.list().length, 2, 'die Liste sollte jetzt zwei Bücher enthalten');
});
test('list gibt alle Bücher zurück oder nur die ungelesenen', () => {
const list = new ReadingList([DUNE, {id: 2, title: 'Emma', author: '', read: true}]);
assert.deepEqual(list.list().map((b) => b.id), [1, 2]);
assert.deepEqual(list.list({unread: true}).map((b) => b.id), [1]);
});
test('markRead markiert ein Buch als gelesen', () => {
const list = new ReadingList([DUNE]);
assert.equal(list.markRead(1).read, true, 'markRead sollte das geänderte Buch zurückgeben');
assert.equal(list.list()[0].read, true, 'das Buch in der Liste sollte gelesen sein');
});
test('Ungültige Eingaben werfen einen BookError mit seinem Feld', async () => {
const list = new ReadingList([DUNE]);
const empty = await caught(() => list.add(' '));
assert.ok(empty instanceof BookError, `add(' ') hat ${empty.name} geworfen, keinen BookError`);
assert.equal(empty.field, 'title');
assert.equal(empty.message, 'title is required');
const missing = await caught(() => list.markRead(9));
assert.ok(missing instanceof BookError, `markRead(9) hat ${missing.name} geworfen, keinen BookError`);
assert.equal(missing.field, 'id');
assert.equal(missing.message, 'no book with id 9');
});
test('Die Liste übersteht Speichern und Laden, und eine fehlende Datei ist eine leere Liste', async () => {
const file = dataFile();
assert.deepEqual((await loadList(file)).list(), [], 'eine fehlende Datei sollte eine leere Liste ergeben');
const list = new ReadingList();
list.add('Dune', 'Frank Herbert');
await saveList(file, list);
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')), [DUNE], 'die Datei sollte die Bücher als JSON enthalten');
assert.deepEqual((await loadList(file)).list(), [DUNE]);
});
test('node main.js fügt Bücher hinzu, markiert und listet sie', () => {
const file = dataFile();
assert.equal(cli('add', 'Dune', '--author', 'Frank Herbert', '--file', file).stdout, 'Added [ ] 1. Dune by Frank Herbert\n');
cli('add', 'The', 'Dispossessed', '--file', file);
assert.equal(cli('read', '1', '--file', file).stdout, 'Read [x] 1. Dune by Frank Herbert\n');
const r = cli('list', '--unread', '--file', file);
assert.equal(r.status, 0, `der Exit-Code war ${r.status}; stderr: ${r.stderr}`);
assert.equal(r.stdout, '[ ] 2. The Dispossessed\n');
});
test('node main.js meldet eine falsche id auf stderr mit Exit-Code 1', () => {
const r = cli('read', '9', '--file', dataFile());
assert.equal(r.status, 1, `der Exit-Code war ${r.status}`);
assert.equal(r.stderr, 'reading-list: no book with id 9\n');
assert.equal(r.stdout, '', 'auf stdout sollte nichts ausgegeben werden');
});
test('Der Client sendet JSON und macht aus einem Fehlerstatus einen ApiError', async () => {
await withServer([DUNE], async (api) => {
assert.deepEqual(await addBook(api, 'Emma', 'Jane Austen'), {id: 2, title: 'Emma', author: 'Jane Austen', read: false});
assert.equal((await markRead(api, 2)).read, true);
const error = await caught(() => request(api + '/books/9', {method: 'PATCH', body: {read: true}}));
assert.ok(error instanceof ApiError, `request hat ${error.name} geworfen, keinen ApiError`);
assert.equal(error.status, 404);
assert.equal(error.message, 'no such book');
});
});
test('Der Client zeigt Loading… und dann die Liste', async () => {
await withServer([DUNE], async (api) => {
const shown = [];
await createListView(api, (text) => shown.push(text))();
assert.deepEqual(shown, ['Loading…', '[ ] 1. Dune by Frank Herbert']);
});
});
test('Der Client behandelt eine Antwort mit Status 500', async () => {
await withServer([], async (api) => {
const shown = [];
await createListView(api, (text) => shown.push(text))('/broken');
assert.deepEqual(shown, ['Loading…', 'The server has a problem. Please try again later.']);
});
});
test('Der Client bricht eine langsame Anfrage ab', async () => {
await withServer([DUNE], async (api) => {
const shown = [];
await createListView(api, (text) => shown.push(text), 50)('/slow');
assert.deepEqual(shown, ['Loading…', 'The server is too slow. Please try again.'], 'eine langsame Antwort sollte in den Timeout laufen');
shown.length = 0;
const load = createListView(api, (text) => shown.push(text));
await Promise.all([load('/slow'), load('/books')]);
await new Promise((resolve) => setTimeout(resolve, 400)); // longer than /slow takes
assert.deepEqual(shown, ['Loading…', 'Loading…', '[ ] 1. Dune by Frank Herbert'], 'ein neueres Laden sollte das ältere abbrechen');
});
});
Das fertige Programm starten
node main.js add Dune --author "Frank Herbert"Referenzlösung
Versuchen Sie zuerst die Etappen. Diese Lösung besteht alle Abnahmetests und die Typprüfung.
main.js
// The command line: node main.js add <title> [--author <name>] | read <id> | list [--unread]
import {parseArgs} from "node:util";
import {BookError, formatBook} from "./books.js";
import {loadList, saveList} from "./store.js";
const USAGE = "Usage: node main.js add <title> [--author <name>] | read <id> | list [--unread] (--file <path>)";
try {
const {values, positionals} = parseArgs({
options: {
file: {type: "string", default: "reading-list.json"},
author: {type: "string", default: ""},
unread: {type: "boolean", default: false}
},
allowPositionals: true
});
const [command, ...words] = positionals;
const list = await loadList(values.file);
if (command === "add") {
const book = list.add(words.join(" "), values.author);
await saveList(values.file, list);
console.log(`Added ${formatBook(book)}`);
} else if (command === "read") {
const book = list.markRead(Number(words[0]));
await saveList(values.file, list);
console.log(`Read ${formatBook(book)}`);
} else if (command === "list") {
const books = list.list({unread: values.unread});
console.log(books.length === 0 ? "No books yet." : books.map(formatBook).join("\n"));
} else {
console.error(USAGE);
process.exitCode = 2;
}
} catch (error) {
if (!(error instanceof BookError) && error.code !== "ERR_PARSE_ARGS_UNKNOWN_OPTION") throw error;
console.error(`reading-list: ${error.message}`);
process.exitCode = 1;
}
books.js
// The reading list itself: pure logic, no files, no network.
export class BookError extends Error {
constructor(field, message) {
super(message);
this.name = "BookError";
this.field = field;
}
}
export class ReadingList {
#books;
constructor(books = []) {
this.#books = books.map((book) => ({...book}));
}
add(title, author = "") {
if (typeof title !== "string" || title.trim() === "") {
throw new BookError("title", "title is required");
}
const id = Math.max(0, ...this.#books.map((book) => book.id)) + 1;
const book = {id, title: title.trim(), author: author.trim(), read: false};
this.#books.push(book);
return {...book};
}
markRead(id) {
const book = this.#books.find((b) => b.id === id);
if (!book) throw new BookError("id", `no book with id ${id}`);
book.read = true;
return {...book};
}
list({unread = false} = {}) {
return this.#books.filter((book) => !unread || !book.read).map((book) => ({...book}));
}
toJSON() {
return this.list();
}
}
export const formatBook = (book) =>
`${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;
store.js
// Keeps the list in a JSON file between runs.
import {readFile, writeFile} from "node:fs/promises";
import {ReadingList} from "./books.js";
export async function loadList(file) {
try {
return new ReadingList(JSON.parse(await readFile(file, "utf8")));
} catch (error) {
if (error.code === "ENOENT") return new ReadingList(); // the first run: no file yet
throw error;
}
}
export async function saveList(file, list) {
await writeFile(file, JSON.stringify(list, null, 2) + "\n");
}
client.js
// The fetch client for the reading-list server.
import {formatBook} from "./books.js";
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
// Sends a request and returns the JSON answer; a status that is not ok becomes an ApiError.
export async function request(url, {method = "GET", body, signal} = {}) {
const response = await fetch(url, {
method,
signal,
headers: body === undefined ? {} : {"Content-Type": "application/json"},
body: body === undefined ? undefined : JSON.stringify(body)
});
const data = await response.json();
if (!response.ok) throw new ApiError(response.status, data.error ?? `HTTP ${response.status}`);
return data;
}
export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});
export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});
// Loads the list and tells render what to show. A new load cancels the one before it.
export function createListView(api, render, timeoutMs = 1000) {
let controller = null;
return async function load(path = "/books") {
controller?.abort();
controller = new AbortController();
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
render("Loading…");
try {
const books = await request(api + path, {signal});
render(books.length === 0 ? "No books yet." : books.map(formatBook).join("\n"));
} catch (error) {
if (error.name === "AbortError") return; // a newer load replaced this one
if (error.name === "TimeoutError") render("The server is too slow. Please try again.");
else if (error instanceof ApiError && error.status >= 500) render("The server has a problem. Please try again later.");
else render(`Could not load the list: ${error.message}`);
}
};
}
server.js
// A small local JSON server for the reading list, made with node:http. You do not need to change it.
import {createServer} from "node:http";
export async function startServer(books = []) {
let nextId = books.length + 1;
const server = createServer(async (request, response) => {
const json = (status, value) => {
response.writeHead(status, {"Content-Type": "application/json"});
response.end(JSON.stringify(value));
};
let body = "";
for await (const chunk of request) body += chunk;
const {method, url} = request;
const match = url.match(/^\/books\/(\d+)$/);
if (method === "GET" && url === "/books") json(200, books);
else if (method === "POST" && url === "/books") {
const {title, author} = JSON.parse(body || "{}");
if (typeof title !== "string" || title.trim() === "") return json(400, {error: "title is required"});
const book = {id: nextId++, title: title.trim(), author: author ?? "", read: false};
books.push(book);
json(201, book);
} else if (method === "PATCH" && match) {
const book = books.find((b) => b.id === Number(match[1]));
if (!book) return json(404, {error: "no such book"});
book.read = JSON.parse(body || "{}").read === true;
json(200, book);
} else if (url === "/broken") json(500, {error: "database is down"});
else if (url === "/slow") {
// answers after 300 ms, unless the client gave up
const timer = setTimeout(() => json(200, books), 300);
response.on("close", () => clearTimeout(timer));
} else json(404, {error: "not found"});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
return {
url: `http://127.0.0.1:${server.address().port}`,
close: () => new Promise((resolve) => server.close(resolve))
};
}
Weiterentwickeln
- Fügen Sie einen Befehl remove <id> hinzu, dazu eine passende Route DELETE /books/<id> in server.js und eine Funktion removeBook in client.js.
- Bringen Sie den Client auf eine Seite: eine index.html mit einer Liste und einem Knopf zum Neuladen, bei der render in ein Element schreibt statt auszugeben.
- Wiederholen Sie eine Antwort mit Status 503 bis zu dreimal mit wachsender Wartezeit, wie in Lektion I6.2, aber nie einen 4xx-Status.
- Schreiben Sie die Datendatei atomar: zuerst in eine temporäre Datei im selben Ordner, dann per rename über die alte.
Projekte sind Übung: Ihre Prüfungen laufen im Browser oder auf Ihrem Rechner und zählen nie für eine Bescheinigung.
Quellen
- Test runner (Node.js v24 documentation; englisch)
- util.parseArgs([config]) (Node.js v24 documentation; englisch)
- File system: fsPromises.readFile and writeFile (Node.js v24 documentation; englisch)
- Error (MDN; englisch)
- Using the Fetch API (MDN; englisch)
- AbortSignal: any() static method (MDN; englisch)
- AbortSignal: timeout() static method (MDN; englisch)
Zuletzt geprüft am 5. Oktober 2026
Zurück zum Kurs JavaScript