Zum Inhalt springen

Ein eigenes Backend mit Node

Seit Teil 7 holt die Fallbeispiel-App ihre Daten von der Kurs-API im Ordner api des Kursrepos. Die Kurs-API läuft mit Node und dem eingebauten Modul node:http, ohne Framework. Wer im Projekt Daten speichern will, die keine öffentliche API liefert, braucht ein solches Backend selbst. Diese Seite zeigt eine kleine API für den Katalog, die du lesen und erweitern kannst.

Laut Projektauftrag bringt ein eigenes Backend vier Bonuspunkte. Bewertet wird sonst nur das Frontend, plane also zuerst die SPA und danach den Server. Ein Backend lohnt sich, wenn deine App Daten anlegt oder ändert, die andere Personen sehen sollen, oder wenn es Daten aus mehreren Quellen zusammenführt. Für eine App, die nur liest, genügen oft eine öffentliche API oder eine JSON-Datei.

Das Beispiel liegt im Kursrepo unter optional/backend/de. Die Funktion createHandler beantwortet jede Anfrage. Sie prüft Methode und Pfad, sucht mit ?q= im Namen der Dinge und nimmt mit POST den Wunsch eines Mitglieds für ein neues Ding an. Den Inhalt einer Anfrage liest sie Stück für Stück bis zum Ende, und sie prüft ihn, bevor sie etwas speichert.

app.js
// A small JSON API for the catalog with node:http and without packages. GET /api/items lists the
// things, ?q= searches their names, GET /api/items/:id returns one thing and POST /api/wishes
// takes the wish of a member for a new thing.
const MAX_BODY = 10_000
function send(response, status, body) {
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }).end(JSON.stringify(body))
}
// The body arrives in pieces of bytes, and a character such as ü can be split between two of them.
// The API joins the pieces, gives up above MAX_BODY bytes and reads the whole as UTF-8 once.
async function readJson(request) {
const chunks = []
let size = 0
for await (const chunk of request) {
size += chunk.length
if (size > MAX_BODY) return undefined
chunks.push(chunk)
}
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
} catch {
return undefined
}
}
export function createHandler(items) {
const wishes = []
return async (request, response) => {
const url = new URL(request.url, 'http://localhost')
if (request.method === 'GET' && url.pathname === '/api/items') {
const query = (url.searchParams.get('q') ?? '').toLowerCase()
return send(response, 200, items.filter((item) => item.name.toLowerCase().includes(query)))
}
const match = /^\/api\/items\/(\d+)$/.exec(url.pathname)
if (request.method === 'GET' && match !== null) {
const item = items.find((candidate) => candidate.id === Number(match[1]))
if (item === undefined) return send(response, 404, { error: 'Dieses Ding gibt es nicht.' })
return send(response, 200, item)
}
if (request.method === 'POST' && url.pathname === '/api/wishes') {
const body = await readJson(request)
const name = typeof body?.name === 'string' ? body.name.trim() : ''
if (name === '' || name.length > 60) {
return send(response, 422, { error: 'Der Wunsch braucht einen Namen mit höchstens 60 Zeichen.' })
}
const wish = { id: wishes.length + 1, name }
wishes.push(wish)
return send(response, 201, wish)
}
send(response, 404, { error: 'Diese Adresse kennt die API nicht.' })
}
}

server.js startet den Server auf Port 3001. npm run dev startet ihn mit node --watch neu, sobald du eine Datei änderst.

server.js
// Starts the API with npm start on http://localhost:3001, PORT chooses another port
import { createServer } from 'node:http'
import { createHandler } from './app.js'
import items from './data/items.json' with { type: 'json' }
const server = createServer(createHandler(items))
server.listen(Number(process.env.PORT ?? 3001), () => {
console.log(`Die API läuft auf http://localhost:${server.address().port}/api/items`)
})

Die Tests starten die API auf einem freien Port und fragen sie mit fetch ab, so wie es eine SPA tut. Node bringt dafür node:test und fetch mit, du brauchst keine zusätzlichen Pakete.

app.test.js
let server
let base
// Every test run starts the API on a free port and stops it at the end
before(async () => {
server = createServer(createHandler(items))
await new Promise((resolve) => server.listen(0, resolve))
base = `http://localhost:${server.address().port}`
})
after(() => server.close())
// Sends a JSON body in two pieces, split in the middle of the first character that needs two bytes
function sendInTwoPieces(path, body) {
const bytes = Buffer.from(JSON.stringify(body))
const middle = bytes.findIndex((byte) => byte >= 0xc0) + 1
return new Promise((resolve, reject) => {
const headers = { 'Content-Type': 'application/json', 'Content-Length': bytes.length }
const outgoing = sendRequest(`${base}${path}`, { method: 'POST', headers }, async (response) => {

Die Wünsche liegen nur im Speicher und sind nach einem Neustart weg. Die Kurs-API schreibt ihre Daten deshalb in Dateien, und ein Projekt mit vielen Daten nimmt eine Datenbank.

Die SPA spricht mit dem Backend am einfachsten über den Proxy von Vite, wie im Kapitel Daten von einer API. Für den Browser liegen App und API dann am selben Origin, und es braucht kein CORS. Läuft die API unter einer anderen Adresse, muss sie dem Browser mit dem Header Access-Control-Allow-Origin erlauben, ihre Antworten an die App weiterzugeben. Die Kurs-API zeigt das in api/src/app.js. Zugangsdaten für eine Datenbank oder einen anderen Dienst gehören in Umgebungsvariablen des Servers und nie in den Code der SPA, denn alles im Build der SPA kann jede Person im Browser lesen.

Bei einer grösseren API nehmen dir Frameworks Routen, das Lesen von JSON und die Behandlung von Fehlern ab. Express gibt seit Version 5 abgelehnte Promises von selbst an die Behandlung von Fehlern weiter. Fastify prüft Anfragen mit JSON Schema, und Hono baut auf den Web-Standards Request und Response auf. Geheime Werte liest Node mit node --env-file=.env aus einer Datei, die nicht ins Repository gehört.

Texte und Abbildungen stehen unter CC BY-SA 4.0, der Code unter der MIT-Lizenz und die Daten des Kurses unter CC0 1.0. Die Schrift der Abbildungen ist Kalam unter der SIL Open Font License 1.1. Die Quellen liegen auf GitHub.