Sebuah aplikasi cuaca pada dasarnya adalah mesin penerjemah: angka dari server di satu sisi, layar yang dipahami manusia di sisi lain. Sebelum bisa menerjemahkan apa pun, program harus berhasil meminta dan menerima datanya — dan tahap inilah yang paling sering disalahpahami pemula. fetch terlihat seperti fungsi sederhana, tetapi di baliknya ada janji (promise) yang bisa terpenuhi atau gagal, respons yang bisa berstatus sukses maupun galat, dan isi yang bisa saja tidak sesuai harapan. Bagian pertama proyek ini meletakkan fondasi itu: satu fungsi muatCuaca(kota) yang jujur terhadap semua kemungkinan tersebut.
Kita juga menghadapi kendala praktis: course ini tidak boleh memanggil internet. Kabar baiknya, keterampilan yang dilatih persis sama — menulis permintaan, membaca status, menangani kegagalan — hanya alamatnya yang berbeda. Karena itu setiap contoh di bagian ini berbicara dengan server tiruan yang kita bangun sendiri.
Berlatih Tanpa Internet: Server Tiruan di Port 18080 #
Server tiruan kita adalah program Node kecil yang dibuat dengan modul node:http: ia menunggu koneksi di localhost:18080, membaca parameter kota dari URL, lalu menjawab dengan JSON — atau kode 404 bila kotanya tidak ada dalam datanya. Pola ini sama dengan yang dipakai pada section Network dan API untuk contoh-contoh fetch: servernya tertanam di berkas demo, sehingga satu perintah node demo-offline.mjs mencakup semuanya — menyalakan server, menjalankan permintaan, lalu mematikan server dengan server.close() agar demo berakhir dengan rapi.
Data yang disajikan server tiruan berupa peta kecil dua kota:
Parameter kota | Isi JSON |
|---|---|
bandung | {"kota":"Bandung","suhu":24.6,"kelembapan":78,"deskripsi":"Berawan sebagian"} |
surabaya | {"kota":"Surabaya","suhu":31.2,"kelembapan":65,"deskripsi":"Cerah berawan"} |
| selain itu | status 404 dengan pesan JSON |
Bentuk data ini menjadi kesepakatan seluruh proyek: kota berupa string, suhu berupa number dalam celsius, kelembapan berupa number 0–100, dan deskripsi berupa string. Fungsi muatCuaca mengembalikan objek persis seperti itu.
ApiError: Galat yang Menyebut Asalnya #
Pertama-tama, kita definisikan galat khusus. fetch yang gagal jaringan melempar TypeError, tetapi respons 404 atau 500 dari server bukan kegagalan fetch — itu informasi yang harus disampaikan dengan wadah sendiri. Class ApiError berikut mengisi peran itu, pola yang sama dengan class galat khusus pada proyek kalkulator:
export class ApiError extends Error {
constructor(pesan, status) {
super(pesan);
this.name = 'ApiError';
this.status = status;
}
}extends Error:ApiErrormewarisi perilakuErrorstandar — propertimessagedanstack— sehingga diperlakukan seperti galat lain oleh engine.super(pesan): pesan diteruskan ke constructorErroragargalat.messageberisi teks yang kita tulis.this.status: kode status HTTP disimpan sebagai data tambahan. Pemanggil bisa membedakan404(kota tidak ada) dari500(server bermasalah) tanpa mengurai string pesan.
Fungsi muatCuaca #
Berikut cuaca.mjs versi pertama — berkas lengkap pada akhir Bagian 1 ini. Fungsi formatter, state machine, dan optimasi baru menyusul di bagian-bagian berikutnya:
// cuaca.mjs — logika inti aplikasi cuaca, terpisah dari tampilan (DOM).
// Tidak ada satu pun kode browser di berkas ini, sehingga seluruh
// perilakunya bisa diuji dengan node --test tanpa membuka halaman web.
// --- Mengambil data: fetch, response.ok, dan ApiError ---
// Galat khusus untuk kegagalan yang berasal dari sisi API/server.
export class ApiError extends Error {
constructor(pesan, status) {
super(pesan);
this.name = 'ApiError';
this.status = status;
}
}
// Server tiruan lokal (node:http) hidup di dalam berkas demo; kita tidak
// memanggil API internet apa pun di course ini. Port 18080 adalah
// kesepakatan proyek: server tiruan menunggu di situ.
const URL_DASAR = 'http://localhost:18080';
// Mengambil data cuaca satu kota dari server.
export async function muatCuaca(kota, { baseUrl = URL_DASAR } = {}) {
const url = `${baseUrl}/cuaca?kota=${encodeURIComponent(kota)}`;
const respons = await fetch(url);
if (!respons.ok) {
throw new ApiError(`Server menjawab ${respons.status} untuk kota "${kota}"`, respons.status);
}
const data = await respons.json();
if (typeof data.kota !== 'string' || typeof data.suhu !== 'number') {
throw new ApiError('Format data cuaca tidak dikenal', respons.status);
}
return data;
}Mari kita bedah baris-baris kuncinya:
- Baris 22 (
export async function muatCuaca(kota, { baseUrl = URL_DASAR } = {})): fungsi asynchronous dengan satu parameter kedua berupa objek opsi. DefaultURL_DASARmembuat pemanggil biasa cukup menulismuatCuaca('Bandung'), sementara uji bisa mengarahkan fungsi ke server lain lewat opsibaseUrl. - Baris 23 (
const url = ...): nama kota dimasukkan ke query string lewatencodeURIComponent, yang mengubah karakter khusus menjadi aman untuk URL — spasi pada'Jakarta Pusat'menjadi%20. Menggabungkan string tanpa fungsi ini bisa menghasilkan URL yang rusak. - Baris 24 (
const respons = await fetch(url)): permintaan dikirim dan program menunggu jawabannya. Promise darifetchterpenuhi begitu header respons diterima. - Baris 26–28 (
if (!respons.ok) ...): pemeriksaan yang paling sering dilupakan.fetchhanya menolak bila jaringannya gagal; respons404maupun500tetap dianggap “selesai” olehfetch. Propertirespons.okbernilaitruehanya untuk status 200–299, dan di sanalah kita melemparApiErroryang membawa kode statusnya. - Baris 31–33 (
const data = await respons.json(); ...): isi respons dibaca sebagai JSON, lalu bentuknya divalidasi sekilas — data dari luar adalah teks asing sampai terbukti sebaliknya. Bilakotabukan string atausuhubukan number,ApiErrordilempar alih-alih membiarkan data cacat menyebar ke bagian lain aplikasi. - Baris 34 (
return data;): objek polos hasil parsing dikembalikan kepada pemanggil.muatCuacatidak tahu dan tidak peduli bagaimana data itu akan ditampilkan.
Demo: Menyalakan dan Mematikan Server Tiruan #
Berkas demo-offline.mjs memuat server tiruannya sendiri, menjalankan tiga permintaan, lalu mematikan servernya:
// demo-offline.mjs — membuktikan muatCuaca bekerja tanpa internet.
// Server tiruan node:http hidup di dalam berkas ini: menyajikan data
// JSON, lalu dimatikan lagi dengan server.close() di akhir demo.
import http from 'node:http';
import { ApiError, muatCuaca } from './cuaca.mjs';
const DATA_CUACA = {
'bandung': { kota: 'Bandung', suhu: 24.6, kelembapan: 78, deskripsi: 'Berawan sebagian' },
'surabaya': { kota: 'Surabaya', suhu: 31.2, kelembapan: 65, deskripsi: 'Cerah berawan' },
};
const server = http.createServer((permintaan, jawaban) => {
const url = new URL(permintaan.url, 'http://localhost');
const kota = (url.searchParams.get('kota') ?? '').toLowerCase();
const data = DATA_CUACA[kota];
jawaban.setHeader('Content-Type', 'application/json');
// Halaman antarmuka dibuka dari origin lain, sehingga respons perlu
// header ini agar boleh dibaca browser (lihat chapter CORS).
jawaban.setHeader('Access-Control-Allow-Origin', '*');
if (data === undefined) {
jawaban.statusCode = 404;
jawaban.end(JSON.stringify({ pesan: `Data kota "${kota}" tidak tersedia` }));
return;
}
jawaban.end(JSON.stringify(data));
});
// Server menunggu koneksi di port 18080 (URL_DASAR di cuaca.mjs).
await new Promise((selesai) => server.listen(18080, selesai));
const bandung = await muatCuaca('Bandung');
console.log('Bandung :', JSON.stringify(bandung));
const surabaya = await muatCuaca('Surabaya');
console.log('Surabaya :', JSON.stringify(surabaya));
try {
await muatCuaca('Bulan');
} catch (galat) {
const status = galat instanceof ApiError ? galat.status : '-';
console.log('Bulan :', `${galat.name} (status ${status}) — ${galat.message}`);
}
// Pekerjaan selesai: matikan server agar demo berakhir dengan rapi.
await new Promise((selesai) => server.close(selesai));Jalankan dari folder bagian ini:
node demo-offline.mjsOutput yang Dihasilkan:
Bandung : {"kota":"Bandung","suhu":24.6,"kelembapan":78,"deskripsi":"Berawan sebagian"}
Surabaya : {"kota":"Surabaya","suhu":31.2,"kelembapan":65,"deskripsi":"Cerah berawan"}
Bulan : ApiError (status 404) — Server menjawab 404 untuk kota "Bulan"Mari kita bedah mekanisme server tiruannya:
- Baris 8 (
http.createServer(...)): callback menerima dua object —permintaanyang datang danjawabanyang akan dikirim. Setiap koneksifetchmasuk lewat sini. - Baris 9–10 (
new URL(...)dansearchParams.get('kota')): URL permintaan diurai, parameterkotadiambil lalu dijadikan huruf kecil, sehinggaBandungdanbandungmenemukan entri yang sama. Operator??menjaga bila parameter itu tidak ada sama sekali. - Baris 15–23: jawaban dikirim sebagai JSON. Bila kotanya tidak ada dalam
DATA_CUACA, server mengirim status404dengan pesan JSON — keputusan kecil yang membuat sisi server pun sopan terhadap pemanggilnya. - Baris 26 (
await new Promise((selesai) => server.listen(18080, selesai))):listenmenerima callback saat server siap; callback itu kita bungkus sebagai Promise agar bisa di-await— pola yang sudah kita pakai pada section Asynchronous JavaScript. - Baris 40–41:
server.close()dipanggil lewat Promise yang sama polanya. Tanpa baris ini, proses Node tidak pernah berhenti karena server masih menunggu koneksi — itulah alasan setiap demo di proyek ini selalu menutup servernya sendiri.
Tiga baris keluaran menceritakan tiga jalur muatCuaca: dua kota yang dikenal mengembalikan objek JSON lengkap, dan kota Bulan — yang memang tidak ada dalam data server — berujung pada ApiError dengan status 404 dan pesan yang menyebut kotanya. Jalur ketiga ini bukti bahwa galat dari server kini berupa objek yang bisa diperiksa, bukan sekadar teks.
Antarmuka Pertama #
Kerangka antarmuka disimpan sebagai index.html.txt — salin ulang sebagai index.html bila ingin mencobanya. Pada Bagian 1 kartunya masih jujur menampilkan JSON mentah; pemformatan yang layak dilihat manusia adalah pekerjaan Bagian 2:
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aplikasi Cuaca</title>
<link rel="stylesheet" href="../styles/styles.css">
</head>
<body>
<main class="panel">
<h1>Cuaca Kota</h1>
<form id="formulir-cari">
<input
id="kolom-kota"
name="kota"
type="text"
placeholder="Contoh: Bandung"
autocomplete="off"
>
<button type="submit">Lihat</button>
</form>
<section id="kartu-cuaca" class="kartu">
<p>Ketik nama kota untuk melihat cuacanya.</p>
</section>
</main>
<script type="module">
import { muatCuaca } from './cuaca.mjs';
const formulir = document.querySelector('#formulir-cari');
const kartu = document.querySelector('#kartu-cuaca');
formulir.addEventListener('submit', async (event) => {
event.preventDefault();
const kota = new FormData(formulir).get('kota').trim();
if (kota === '') return;
try {
const data = await muatCuaca(kota);
kartu.textContent = JSON.stringify(data, null, 2);
} catch (galat) {
kartu.textContent = `Gagal memuat data: ${galat.message}`;
}
});
</script>
</body>
</html>Skripnya singkat karena logikanya sudah hidup di cuaca.mjs:
event.preventDefault(): menghentikan perilaku bawaan form yang me-reload halaman; penggantinya adalah alur asynchronous kita sendiri.new FormData(formulir).get('kota'): membaca nilai input lewat atributname="kota", lalutrim()membuang spasi yang tidak disengaja. Input kosong langsung diabaikan.await muatCuaca(kota): seluruh kerumitan jaringan diringkas menjadi satu baris; yang tersisa di antarmuka hanyalah dua kemungkinan — data atau galat.kartu.textContent = ...: data ditulis lewattextContent, bukaninnerHTML, sesuai aturan Safe DOM Manipulation. Isi JSON dan pesan galat adalah data, bukan markup.
Tampilannya diatur oleh styles/styles.css — panel putih di atas latar biru muda, form satu baris, dan kartu dengan white-space: pre-wrap agar JSON multi baris dari JSON.stringify(data, null, 2) tetap rapi:
/* styles.css — tampilan aplikasi cuaca */
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #e0f2fe;
font-family: system-ui, sans-serif;
}
.panel {
width: 300px;
padding: 20px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.panel h1 {
margin: 0 0 12px;
font-size: 1.25rem;
color: #0c4a6e;
}
#formulir-cari {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
#kolom-kota {
flex: 1;
padding: 8px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font-size: 1rem;
}
#formulir-cari button {
padding: 8px 14px;
border: none;
border-radius: 8px;
background-color: #0284c7;
color: #ffffff;
font-size: 1rem;
cursor: pointer;
}
#formulir-cari button:hover {
background-color: #0369a1;
}
.kartu {
min-height: 80px;
padding: 12px;
background-color: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 0.95rem;
color: #334155;
white-space: pre-wrap;
}Membuktikan muatCuaca dengan Uji #
Fondasi ini langsung dikunci uji. Server tiruan versi uji dibangun sekali di awal berkas dengan hook before dan dimatikan di after — portnya 18081, berbeda dari demo, supaya keduanya tidak berebut port bila dijalankan bersamaan. Berikut tests/cuaca.test.mjs pada kondisi Bagian 1:
// tests/cuaca.test.mjs — uji otomatis logika aplikasi cuaca.
// Server tiruan localhost dibangun di sini juga, jadi seluruh uji berjalan
// offline: tidak ada satu pun permintaan yang keluar dari mesin kita.
import http from 'node:http';
import { after, before, describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { ApiError, muatCuaca } from '../src/cuaca.mjs';
const URL_UJI = 'http://localhost:18081';
const DATA_CUACA = {
'bandung': { kota: 'Bandung', suhu: 24.6, kelembapan: 78, deskripsi: 'Berawan sebagian' },
'surabaya': { kota: 'Surabaya', suhu: 31.2, kelembapan: 65, deskripsi: 'Cerah berawan' },
};
let server;
let urlTerakhir = null;
before(async () => {
server = http.createServer((permintaan, jawaban) => {
urlTerakhir = permintaan.url;
const url = new URL(permintaan.url, 'http://localhost');
const kota = (url.searchParams.get('kota') ?? '').toLowerCase();
const data = DATA_CUACA[kota];
jawaban.setHeader('Content-Type', 'application/json');
jawaban.setHeader('Access-Control-Allow-Origin', '*');
if (data === undefined) {
jawaban.statusCode = 404;
jawaban.end(JSON.stringify({ pesan: `Data kota "${kota}" tidak tersedia` }));
return;
}
jawaban.end(JSON.stringify(data));
});
await new Promise((selesai) => server.listen(18081, selesai));
});
after(async () => {
await new Promise((selesai) => server.close(selesai));
});
describe('muatCuaca', () => {
test('mengembalikan data JSON untuk kota yang tersedia', async () => {
const data = await muatCuaca('Bandung', { baseUrl: URL_UJI });
assert.deepStrictEqual(data, DATA_CUACA.bandung);
});
test('melempar ApiError berstatus 404 untuk kota yang tidak ditemukan', async () => {
await assert.rejects(muatCuaca('Bulan', { baseUrl: URL_UJI }), (galat) => {
assert.ok(galat instanceof ApiError);
assert.strictEqual(galat.status, 404);
return true;
});
});
test('mengodekan nama kota menjadi URL yang aman', async () => {
await assert.rejects(muatCuaca('Jakarta Pusat', { baseUrl: URL_UJI }), ApiError);
assert.strictEqual(urlTerakhir, '/cuaca?kota=Jakarta%20Pusat');
});
});Jalankan dari folder proyek:
node --testOutput yang Dihasilkan:
▶ muatCuaca
✔ mengembalikan data JSON untuk kota yang tersedia (27.083954ms)
✔ melempar ApiError berstatus 404 untuk kota yang tidak ditemukan (4.657497ms)
✔ mengodekan nama kota menjadi URL yang aman (3.966005ms)
✔ muatCuaca (38.35981ms)
ℹ tests 3
ℹ suites 1
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 156.873429Tiga uji lulus (pass 3, fail 0). Uji pertama menegaskan bentuk data yang dikembalikan persis sama dengan yang disajikan server, dibandingkan menyeluruh dengan assert.deepStrictEqual. Uji kedua memakai assert.rejects dengan fungsi pemeriksa agar dua hal dicek sekaligus: galatnya memang ApiError dan status-nya 404. Uji ketiga memanfaatkan variabel urlTerakhir yang dicatat server tiruan setiap kali menerima permintaan — cara sederhana untuk membuktikan 'Jakarta Pusat' benar-benar dikirim sebagai Jakarta%20Pusat. Angka durasi berbeda-beda di setiap mesin.
Ringkasan Bagian 1 #
Fondasi aplikasi cuaca telah berdiri: kontrak data empat properti, class ApiError yang membawa kode status, fungsi muatCuaca yang memeriksa response.ok dan memvalidasi bentuk datanya, server tiruan yang hidup di dalam demo dan selalu dimatikan di akhir, antarmuka pertama yang menampilkan JSON mentah, dan tiga uji otomatis yang lulus. Data yang diterima pengguna saat ini masih dalam bentuk yang cocok untuk mesin — 24.6 alih-alih 24,6°C. Menerjemahkannya dengan rapi adalah tugas Bagian 2.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
styles.css
Unduh berkas/* styles.css — tampilan aplikasi cuaca */
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #e0f2fe;
font-family: system-ui, sans-serif;
}
.panel {
width: 300px;
padding: 20px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.panel h1 {
margin: 0 0 12px;
font-size: 1.25rem;
color: #0c4a6e;
}
#formulir-cari {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
#kolom-kota {
flex: 1;
padding: 8px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font-size: 1rem;
}
#formulir-cari button {
padding: 8px 14px;
border: none;
border-radius: 8px;
background-color: #0284c7;
color: #ffffff;
font-size: 1rem;
cursor: pointer;
}
#formulir-cari button:hover {
background-color: #0369a1;
}
.kartu {
min-height: 80px;
padding: 12px;
background-color: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 0.95rem;
color: #334155;
white-space: pre-wrap;
}