diff --git a/src/docs/src/recipe-tags.js b/src/docs/src/recipe-tags.js
index dacb25057..8efa21ea0 100644
--- a/src/docs/src/recipe-tags.js
+++ b/src/docs/src/recipe-tags.js
@@ -18,6 +18,7 @@ const recipeTags = {
teams: 'Teams',
performance: 'Performance',
'data-modeling': 'Data Modeling',
+ net: 'Networking',
};
module.exports = recipeTags;
diff --git a/src/docs/src/recipes/build-an-rss-reader.md b/src/docs/src/recipes/build-an-rss-reader.md
new file mode 100644
index 000000000..7f25c4344
--- /dev/null
+++ b/src/docs/src/recipes/build-an-rss-reader.md
@@ -0,0 +1,153 @@
+---
+title: Build an RSS Reader
+description: Learn how to build an RSS reader with Puter.js, so users can read the latest headlines from any feed.
+tags: [net, kv]
+order: 70
+---
+
+An RSS reader fetches a feed and shows links to its latest articles. Use
+[`puter.net.fetch()`](/Networking/fetch/) to read feeds that do not allow
+cross-origin requests, then cache the headlines with
+[`puter.kv`](/KV/) so reopening the app does not fetch the same feed again.
+
+This example keeps up to 20 headlines for five minutes and lets the user
+refresh them sooner. The cache belongs to the signed-in user, inside your
+app's key-value store. The first request opens the Puter sign-in window if
+the user isn't signed in yet.
+
+## Read the Feed
+
+Fetch the XML, check the HTTP status, and parse it with the browser's
+[DOMParser](https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString).
+Read the title and link from each RSS `item`. Because DOMParser is a browser
+API, this code runs in your app's page:
+
+```js
+async function readFeed(feedUrl) {
+ const response = await puter.net.fetch(feedUrl);
+ if (!response.ok) {
+ throw new Error(`Could not load the feed (HTTP ${response.status}).`);
+ }
+
+ const xml = await response.text();
+ const feed = new DOMParser().parseFromString(xml, 'application/xml');
+ if (feed.querySelector('parsererror') || feed.documentElement.localName !== 'rss') {
+ throw new Error('Expected a valid RSS feed.');
+ }
+
+ const articles = [];
+ for (const item of feed.querySelectorAll('channel > item')) {
+ const link = item.querySelector('link')?.textContent.trim();
+ if (!link) continue;
+
+ let url;
+ try {
+ url = new URL(link, response.url || feedUrl);
+ } catch {
+ continue;
+ }
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') continue;
+
+ articles.push({
+ title: (item.querySelector('title')?.textContent.trim() || 'Untitled').slice(0, 200),
+ url: url.href,
+ });
+ if (articles.length === 20) break;
+ }
+
+ return articles;
+}
+```
+
+The function returns plain objects that can go straight into KV. Items
+without a valid HTTP or HTTPS link are left out. Relative links are resolved
+against the feed's URL.
+
+This parser reads RSS feeds with `channel` and `item` elements. Atom feeds use
+a different format and need a different parser.
+
+## Cache the Headlines
+
+Use [`puter.kv.get()`](/KV/get/) to look for a cached result before fetching.
+Pass an expiry to [`puter.kv.set()`](/KV/set/) so it goes away after five
+minutes. The expiry is a Unix timestamp in seconds:
+
+```js
+async function loadFeed(feedUrl, refresh = false) {
+ const key = `rss:cache:${feedUrl}`;
+ if (!refresh) {
+ const cached = await puter.kv.get(key);
+ if (cached !== null) return cached;
+ }
+
+ const articles = await readFeed(feedUrl);
+ const inFiveMinutes = Math.floor(Date.now() / 1000) + 5 * 60;
+ await puter.kv.set(key, articles, inFiveMinutes);
+ return articles;
+}
+```
+
+A missing or expired key returns `null`. An empty feed returns an empty
+array, which is cached too. Passing `true` for `refresh` fetches a new copy
+and replaces the cache after the feed has been read successfully.
+
+Only titles and links are cached, which keeps each value well under the
+[KV value size limit](/KV/MAX_VALUE_SIZE/).
+
+## Show the Headlines
+
+Add these elements to a page that loads Puter.js:
+
+```html
+
+
+
+```
+
+Put the two functions above and this code in the same script, after the
+elements. It loads a BBC News feed on opening and fetches a fresh copy when
+the user clicks Refresh:
+
+```js
+const feedUrl = 'https://feeds.bbci.co.uk/news/rss.xml';
+const refreshButton = document.getElementById('refresh-feed');
+const status = document.getElementById('feed-status');
+const list = document.getElementById('headlines');
+
+async function showFeed(refresh = false) {
+ refreshButton.disabled = true;
+ status.textContent = 'Loading...';
+
+ try {
+ const articles = await loadFeed(feedUrl, refresh);
+ const rows = articles.map((article) => {
+ const row = document.createElement('li');
+ const link = document.createElement('a');
+ link.textContent = article.title;
+ link.href = article.url;
+ row.append(link);
+ return row;
+ });
+
+ list.replaceChildren(...rows);
+ status.textContent = articles.length ? 'Headlines loaded.' : 'No articles in this feed.';
+ } catch (error) {
+ const message = error?.message ?? error?.error?.message ?? error;
+ status.textContent = `Could not load the headlines: ${message}`;
+ } finally {
+ refreshButton.disabled = false;
+ }
+}
+
+refreshButton.addEventListener('click', () => showFeed(true));
+showFeed();
+```
+
+Titles are assigned through `textContent`, so markup in a feed stays text.
+If a refresh fails, the current headlines stay on the page and the status
+shows the error.
+
+## Notes
+
+- To keep full articles or other large feed data, save it as files instead.
+ See [store files](/recipes/store-files/).
diff --git a/src/docs/src/recipes/check-website-uptime.md b/src/docs/src/recipes/check-website-uptime.md
new file mode 100644
index 000000000..59c4e4708
--- /dev/null
+++ b/src/docs/src/recipes/check-website-uptime.md
@@ -0,0 +1,153 @@
+---
+title: Check Website Uptime
+description: Learn how to check whether a website is up with Puter.js and keep a history of recent checks for each user.
+tags: [net, kv]
+order: 71
+---
+
+To see whether a website is responding, make a request with
+[`puter.net.fetch()`](/Networking/fetch/) and save the result with
+[`puter.kv.set()`](/KV/set/). This gives your app a history of successful
+checks, HTTP errors and connection failures.
+
+This example checks a site when the user clicks a button, keeps each result
+for seven days, and shows the latest 20 checks. Each user's history lives in
+their own account. The first check opens the Puter sign-in window if the user
+isn't signed in yet.
+
+## Check the Site
+
+Use a `HEAD` request to read the site's status without downloading its page.
+Check `response.ok` to tell whether it returned a status from `200` to `299`,
+and catch request failures so they can be saved too:
+
+```js
+async function checkWebsite(url) {
+ const started = performance.now();
+ const result = {
+ at: Date.now(),
+ up: false,
+ status: null,
+ durationMs: 0,
+ error: null,
+ };
+
+ try {
+ const response = await puter.net.fetch(url, { method: 'HEAD' });
+ result.up = response.ok;
+ result.status = response.status;
+ } catch (error) {
+ result.error = String(error?.message ?? error?.error?.message ?? error);
+ }
+ result.durationMs = Math.round(performance.now() - started);
+
+ const prefix = `uptime:${encodeURIComponent(url)}:`;
+ const key = `${prefix}${new Date(result.at).toISOString()}:${crypto.randomUUID()}`;
+ const inSevenDays = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60;
+ await puter.kv.set(key, result, inSevenDays);
+ return result;
+}
+```
+
+An HTTP error has a status, such as `503`. A request that fails before getting
+a response has `status: null` and an error message. A failed KV write still
+rejects, so the app can tell the user when a result couldn't be saved.
+
+This example treats every status outside `200` to `299` as down. Change that
+rule if your endpoint uses another status for a successful check. Use an
+endpoint that accepts `HEAD`, because a site that rejects it can return `405`
+even when its pages work. For a `GET` check, cancel the response body with
+`await response.body?.cancel()` after reading the status.
+
+The elapsed time includes connection setup and the network path from the
+user's browser, so each result is one sample of the site's availability.
+
+Each check has its own key. The timestamp sorts checks by time, and the UUID
+keeps two checks made in the same millisecond from replacing each other.
+The third argument to [`puter.kv.set()`](/KV/set/) is an expiry timestamp in
+seconds.
+
+## Read Recent Checks
+
+List the site's keys with [`puter.kv.list()`](/KV/list/). Pass `reverse: true`
+to put the newest first and `limit: 20` to read one page of history:
+
+```js
+async function getHistory(url) {
+ const page = await puter.kv.list({
+ pattern: `uptime:${encodeURIComponent(url)}:*`,
+ returnValues: true,
+ reverse: true,
+ limit: 20,
+ fetchUntilFull: true,
+ });
+
+ return page.items.map((entry) => entry.value);
+}
+```
+
+With `limit`, [`puter.kv.list()`](/KV/list/) returns a page object whose
+`items` hold the key-value pairs. The `fetchUntilFull` option fills the page
+when possible, even if expired keys are skipped. A site with no saved checks
+returns an empty list.
+
+To show older results, keep the page's `cursor` and request the next page, as
+in [store a large collection](/recipes/store-large-collection/).
+
+## Show the History
+
+Add a button, a status message and a place for the history to a page that
+loads Puter.js:
+
+```html
+
+
+
+```
+
+Put the two functions above and this code in the same script, after the
+elements. Change `targetUrl` to the site or health endpoint you want to check:
+
+```js
+const targetUrl = 'https://example.com/';
+const checkButton = document.getElementById('check-site');
+const status = document.getElementById('check-status');
+const history = document.getElementById('check-history');
+
+async function showHistory() {
+ const checks = await getHistory(targetUrl);
+ history.textContent = checks.length ? checks.map((check) => {
+ const time = new Date(check.at).toLocaleString();
+ const outcome = check.up ? 'Up' : 'Down';
+ const detail = check.status === null ? check.error : `HTTP ${check.status}`;
+ return `${time} - ${outcome} - ${detail} - ${check.durationMs} ms`;
+ }).join('\n') : 'No checks yet.';
+}
+
+checkButton.addEventListener('click', async () => {
+ checkButton.disabled = true;
+ status.textContent = 'Checking...';
+
+ try {
+ const result = await checkWebsite(targetUrl);
+ status.textContent = result.up ? 'The site responded successfully.' : 'The check failed.';
+ await showHistory();
+ } catch (error) {
+ const message = error?.message ?? error?.error?.message ?? error;
+ status.textContent = `Could not save or load the results: ${message}`;
+ } finally {
+ checkButton.disabled = false;
+ }
+});
+
+showHistory().catch((error) => {
+ const message = error?.message ?? error?.error?.message ?? error;
+ status.textContent = `Could not load the history: ${message}`;
+});
+```
+
+The button stays disabled until the check and history refresh finish. A
+connection failure is saved as a failed check, and its message appears in
+the history. Checks run only when the user clicks the button, so closing the
+page stops them.
+
diff --git a/src/docs/src/recipes/connect-to-a-server.md b/src/docs/src/recipes/connect-to-a-server.md
new file mode 100644
index 000000000..a373f946e
--- /dev/null
+++ b/src/docs/src/recipes/connect-to-a-server.md
@@ -0,0 +1,152 @@
+---
+title: Connect to a Server
+description: Learn how to open TCP and TLS connections from the browser with Puter.js, so your app can talk to any server on any port.
+tags: [net]
+order: 66
+---
+
+To talk to a server over a TCP connection, use the
+[`puter.net.Socket()`](/Networking/Socket/) constructor. For an encrypted
+connection, use [`puter.net.tls.TLSSocket()`](/Networking/TLSSocket/).
+Both take a hostname and a port, then send and receive data through events.
+Browsers can't open raw TCP connections on their own, so these sockets give
+your app's page direct access to servers such as mail, database and WHOIS
+servers. The first connection opens the Puter sign-in window if the user
+isn't signed in yet.
+
+For most HTTP requests, [use `puter.net.fetch()`](/recipes/make-http-requests/).
+A socket is useful when you need to send the protocol's bytes yourself.
+
+## Send an HTTP Request
+
+To connect to an HTTP server, pass its hostname and port `80`. Wait for the
+`open` event, then send the request with the
+[`write()`](/Networking/Socket/) method:
+
+```js
+const socket = new puter.net.Socket('example.com', 80);
+const decoder = new TextDecoder();
+
+socket.on('open', () => {
+ socket.write('GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n');
+});
+
+socket.on('data', (data) => {
+ console.log(decoder.decode(data, { stream: true }));
+});
+
+socket.on('error', (error) => {
+ console.error('Connection failed:', error.message);
+});
+
+socket.on('close', () => {
+ const remaining = decoder.decode();
+ if (remaining) console.log(remaining);
+});
+```
+
+The server sends bytes in `data` events. This prints the HTTP response,
+including its headers. The `Connection: close` header asks the server to close
+the connection after sending it. Each `\r\n` ends a line, and the empty line
+ends the request headers.
+
+Passing `{ stream: true }` to `TextDecoder` keeps a character that's split
+across two chunks intact. The last `decode()` call in the `close` handler
+flushes any remaining text.
+
+## Use TLS
+
+To make the same request over an encrypted connection, use
+[`puter.net.tls.TLSSocket()`](/Networking/TLSSocket/) and port `443`.
+Its connection events are `tlsopen`, `tlsdata` and `tlsclose`. Errors still
+use `error`:
+
+```js
+const socket = new puter.net.tls.TLSSocket('example.com', 443);
+const decoder = new TextDecoder();
+
+socket.on('tlsopen', () => {
+ socket.write('GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n');
+});
+
+socket.on('tlsdata', (data) => {
+ console.log(decoder.decode(data, { stream: true }));
+});
+
+socket.on('error', (error) => {
+ console.error('Connection failed:', error.message);
+});
+
+socket.on('tlsclose', () => {
+ const remaining = decoder.decode();
+ if (remaining) console.log(remaining);
+});
+```
+
+Use a TLS socket on a port where the server accepts TLS connections.
+
+## Send Bytes
+
+The [`write()`](/Networking/Socket/) method also accepts a `Uint8Array` or an
+`ArrayBuffer`.
+Use `TextEncoder` to turn text into UTF-8 bytes. This example asks the
+[IANA WHOIS server](https://www.iana.org/whois) for information about `.com`:
+
+```js
+const socket = new puter.net.Socket('whois.iana.org', 43);
+const encoder = new TextEncoder();
+const decoder = new TextDecoder();
+let reply = '';
+
+socket.on('open', () => {
+ socket.write(encoder.encode('com\r\n'));
+});
+
+socket.on('data', (data) => {
+ reply += decoder.decode(data, { stream: true });
+});
+
+socket.on('error', (error) => {
+ console.error('WHOIS request failed:', error.message);
+});
+
+socket.on('close', (hadError) => {
+ reply += decoder.decode();
+ if (!hadError) console.log(reply);
+});
+```
+
+[WHOIS](https://www.rfc-editor.org/rfc/rfc3912) uses port `43`, takes a query
+ending in `\r\n`, and closes the connection when the reply is complete.
+The example collects all the chunks before printing it.
+
+A `data` event can contain part of a message or several messages, so use the
+protocol's rules to decide when a reply is complete. For a binary protocol,
+work with the received `Uint8Array` directly and decode only the bytes that
+the protocol defines as text.
+
+## Handle Errors and Close
+
+A socket connects after you create it. Listen for `error` to handle a failed
+connection, and for `close` to know when it ends. The `hadError` argument is
+`true` when the connection closes because of an error:
+
+```js
+const socket = new puter.net.Socket('does-not-exist.invalid', 80);
+
+socket.on('error', (error) => {
+ console.error('Connection failed:', error.message);
+});
+
+socket.on('close', (hadError) => {
+ console.log('Connection closed. Had an error:', hadError);
+});
+```
+
+To close a connection yourself, call the [`close()`](/Networking/Socket/)
+method on the socket you opened:
+
+```js
+socket.close();
+```
+
diff --git a/src/docs/src/recipes/make-http-requests.md b/src/docs/src/recipes/make-http-requests.md
new file mode 100644
index 000000000..cc8052c1d
--- /dev/null
+++ b/src/docs/src/recipes/make-http-requests.md
@@ -0,0 +1,112 @@
+---
+title: Make HTTP Requests
+description: Learn how to make HTTP requests from the browser with Puter.js, so your app can call any API or website, even ones that block cross-origin requests.
+tags: [net]
+order: 65
+---
+
+The browser's `fetch()` blocks a response when the server doesn't allow
+cross-origin requests, so many APIs and websites can't be called from your
+app's page. The [`puter.net.fetch()`](/Networking/fetch/) method makes the
+same HTTP and HTTPS requests without that restriction. It takes the same
+arguments as `fetch()` and returns a `Response`.
+
+The first request opens the Puter sign-in window if the user isn't signed in
+yet.
+
+## Fetch a JSON API
+
+To fetch JSON, pass the API's URL, then read the response with `json()`:
+
+```js
+const response = await puter.net.fetch('https://httpbin.org/get');
+const data = await response.json();
+
+console.log(data);
+```
+
+The method returns a `Response` object. For a page or a plain text file, use
+`response.text()` instead.
+
+## Send JSON
+
+To send JSON, pass `method`, `headers` and `body` in the second argument.
+Use `JSON.stringify()` to turn the object into a string and set `Content-Type`
+so the server knows how to read it. Custom headers go in the same object:
+
+```js
+const response = await puter.net.fetch('https://httpbin.org/post', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ 'X-Custom-Header': 'custom-value',
+ },
+ body: JSON.stringify({ message: 'Hello' }),
+});
+
+const data = await response.json();
+
+console.log(data);
+```
+
+This example uses httpbin, which sends back the JSON and headers it received.
+The other request options are listed on
+[`puter.net.fetch()`](/Networking/fetch/).
+
+## Read a Response in Chunks
+
+To process a large response as it arrives, read from `response.body` instead
+of waiting for `text()` or `json()` to collect the whole body:
+
+```js
+const response = await puter.net.fetch('https://httpbin.org/stream-bytes/102400');
+const reader = response.body.getReader();
+
+try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ console.log(value);
+ }
+} finally {
+ reader.releaseLock();
+}
+```
+
+Each `value` is a `Uint8Array` of bytes. Chunk sizes can vary. Some responses,
+such as those to a `HEAD` request, have no body, so `response.body` is `null`.
+
+## Handle a Failed Request
+
+A request can fail before you get a response, for example when the host cannot
+be reached. Catch the error with `try`/`catch`. HTTP errors such as `404` or
+`500` still return a response, so check `response.ok` before reading the body:
+
+```js
+try {
+ const response = await puter.net.fetch('https://httpbin.org/get');
+ if (!response.ok) {
+ throw new Error(`Request failed (HTTP ${response.status}).`);
+ }
+
+ const data = await response.json();
+ console.log(data);
+} catch (error) {
+ console.error('Could not load the data:', error?.message ?? error);
+}
+```
+
+The `ok` property is `true` for status codes from `200` to `299`. The same
+catch also handles a response that can't be read as JSON.
+
+To try the HTTP error path, replace the URL with
+`https://httpbin.org/status/404`. To try a host that does not exist, use
+`https://does-not-exist.invalid/`.
+
+## Notes
+
+- For an API that needs a secret key, call it from a worker so the key stays
+ out of your app's page. See [keep an API key
+ secret](/recipes/keep-api-key-secret/).