Worker and Subdomain Recipes (#4075)

* Worker recipes medium

* subdomain recipes medium

* refine

---------

Co-authored-by: Reynaldi Chernando <reynaldichernando@gmail.com>
This commit is contained in:
Neal ShahandReynaldi Chernando authored and GitHub committed 2026-10-06 20:09:38 +07:00
1 parent 985c76c9c3
commit dc029b43a8
5 files changed
+648 -10

No files matched your search

@@ -0,0 +1,96 @@
---
title: Add a Backend to Your Website
description: "Learn how to add a backend to a website hosted on Puter, so the site can save data and run code on the server without a separate deploy."
tags: [workers, hosting]
order: 44
---
A static website is a set of files that run in the visitor's browser. Some
features need code that runs on a server instead, such as a visit counter that
every visitor sees, or a call to an API with a secret key. Usually that means
running a server next to your site and deploying the two separately.
If your site is [hosted on Puter](/deployments/#deploy-to-puter), the server
code can be a file inside the site's folder. This file is called a
[dynamic worker](/Workers/dynamic/). It goes online when you publish the site,
and it answers on the site's own address.
## Add the Worker File
Make a folder named `__workers` next to your `index.html`, and put a file in it
whose name ends in `.worker.js`:
```
my-site/
index.html
app.js
__workers/
api.worker.js
```
Write the file with the [`router`](/Workers/router/) API. This worker counts
visits:
```js
// __workers/api.worker.js
router.post('/visits', async () => {
const visits = await me.puter.kv.incr('visits');
return { visits };
});
```
The worker runs as you, the owner of the site. Inside it, `me.puter` is
Puter.js signed in to your account, so the count is saved in your
[key-value store](/KV/) and every visitor adds to the same number. The
[`incr()`](/KV/incr/) method adds 1 and returns the new value.
The data belongs to the site's address. If you publish the same files under
another site name, the worker there starts with an empty store.
The name before `.worker.js` can use lowercase letters, numbers, `-` and `_`.
Each `.worker.js` file directly inside `__workers` is a worker of its own, and
all of them share the same key-value store.
Publish the site the way you already do, and the worker goes online with it.
## Call It From the Site
The worker answers at `/__workers/<name>/` on your site. That prefix is removed
before the request reaches your routes, so `/__workers/api/visits` runs the
`/visits` route.
The worker is on the same address as the page, so the page can call it with a
path instead of a full URL:
```js
const res = await fetch('/__workers/api/visits', { method: 'POST' });
const { visits } = await res.json();
document.getElementById('visits').textContent = `${visits} visits`;
```
The path is the same on every site, so this code does not change if you
publish the site under another name.
To know which visitor is calling, call the worker with
[`puter.workers.exec()`](/Workers/exec/) instead of `fetch()`. See
[Build an API with a worker](/recipes/build-an-api/) for how a route uses the
visitor's Puter account.
## Update the Code
To change the worker, save the new code to its file in the site's folder, for
example by publishing the site again. Shortly after, requests run the new code.
The first request after a change can take a few seconds longer than the ones
after it.
## Keep Secrets in the Worker
Files under `__workers` are never served to visitors. A visitor who opens
`/__workers/api.worker.js` gets a `404`, and only the worker's routes answer.
That makes the worker file a safe place for values the browser should not see,
such as an API key. See [Keep an API key secret](/recipes/keep-api-key-secret/).
For the full path rules and what dynamic workers support, see
[Dynamic Workers](/Workers/dynamic/).
+29 -10
View File
@@ -48,27 +48,46 @@ in the folder is public, so write only what the user means to publish.
## Let the User Pick a Name
The site name becomes the site's address, and users type names with spaces,
capitals and accents. A name can use lowercase letters, digits and hyphens. It
cannot start or end with a hyphen, and it can be at most 64 characters long.
Turn what the user typed into a valid name before you call
[`puter.hosting.create()`](/Hosting/create/):
```js
function toSiteName (text) {
return text
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '') // é -> e
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.slice(0, 64)
.replace(/^-+|-+$/g, '');
}
toSiteName("Grace's Portfolio!"); // 'grace-s-portfolio'
toSiteName('Café Menu 2026'); // 'cafe-menu-2026'
```
Show the result as the user types, so they see the address before they publish.
Site names are shared by every Puter user, so the name the user wants may
already be taken. In that case [`puter.hosting.create()`](/Hosting/create/)
rejects with a `conflict` error. Catch it to ask the user for another name, or
let [`puter.randName()`](/Utils/randName/) pick one nobody has:
already be taken. Some names, such as `www` and `api`, are reserved. In these
cases [`puter.hosting.create()`](/Hosting/create/) rejects with a `conflict` or
`subdomain_reserved` error. Catch it to ask the user for another name, or let
[`puter.randName()`](/Utils/randName/) pick one nobody has:
```js
async function createSite (dir, name) {
try {
return await puter.hosting.create(name, dir);
return await puter.hosting.create(toSiteName(name) || puter.randName(), dir);
} catch (e) {
if (e?.code !== 'conflict') throw e;
if (e?.code !== 'conflict' && e?.code !== 'subdomain_reserved') throw e;
return await puter.hosting.create(puter.randName(), dir);
}
}
```
A name can use lowercase letters, digits and hyphens, and cannot start or end
with a hyphen. Check these
rules in your name field before calling
[`puter.hosting.create()`](/Hosting/create/).
## Remember Which Site Belongs to Which Project
When the user comes back to a project, your app needs to know whether it was
@@ -0,0 +1,138 @@
---
title: Build a Multi-Page Website
description: "Learn how to publish a website with several pages, a shared assets folder and a custom 404 page with Puter.js, with links that work on every page."
tags: [hosting, fs]
order: 58
---
[Add Website Publishing](/recipes/add-website-publishing/) puts a single
`index.html` online. Most sites have more than one page, such as an about page
and a blog, plus some images. Then you need to decide where each file goes and how the pages
link to each other and to the shared files.
A site on Puter serves its folder as it is, like any static host, so the folder
layout decides the URLs.
## Lay Out the Folder
Give each page its own folder with an `index.html` in it, and keep the files
every page uses in an `assets` folder:
```
my-site/
index.html
404.html
about/
index.html
blog/
index.html
first-post/
index.html
assets/
style.css
logo.png
```
A request for a folder is answered with that folder's `index.html`, so these
files get short addresses:
| File | URL |
| --- | --- |
| `index.html` | `/` |
| `about/index.html` | `/about/` |
| `blog/first-post/index.html` | `/blog/first-post/` |
| `assets/style.css` | `/assets/style.css` |
A file named `about.html` would only be served at `/about.html`. The site does
not drop the `.html` from addresses, so use folders when you want URLs without
it.
## Write the Files
Write each page with [`puter.fs.write()`](/FS/write/). The
`createMissingParents` option creates any folder that does not exist yet:
```js
const dir = 'my-site';
function page(title, body) {
return `<!doctype html>
<html>
<head>
<title>${title}</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<nav>
<a href="/"><img src="/assets/logo.png" alt="Home"></a>
<a href="/about/">About</a>
<a href="/blog/">Blog</a>
</nav>
${body}
</body>
</html>`;
}
const files = {
'index.html': page('Home', '<h1>Welcome</h1>'),
'about/index.html': page('About', '<h1>About me</h1>'),
'blog/index.html': page('Blog', '<a href="/blog/first-post/">My first post</a>'),
'blog/first-post/index.html': page('My first post', '<h1>My first post</h1>'),
'404.html': page('Not found', '<h1>This page does not exist</h1>'),
'assets/style.css': 'body { font-family: sans-serif; max-width: 40rem; margin: auto; }',
};
for (const [path, content] of Object.entries(files)) {
await puter.fs.write(`${dir}/${path}`, content, { createMissingParents: true });
}
await puter.fs.write(`${dir}/assets/logo.png`, logoBlob);
const site = await puter.hosting.create(puter.randName(), dir);
```
To publish many files in one call, such as a folder the user dropped onto the
page, use [`puter.fs.upload()`](/FS/upload/) instead.
## Link From the Site Root
Every link above starts with `/`, such as `/assets/style.css`. A link like that
is read from the site's root, so it points to the same file from every page.
A link without the `/`, such as `assets/style.css`, is read from the page's
own address instead, and that address depends on how the visitor got there.
Both `/about/` and `/about` show `about/index.html`, but the browser treats them
as different folders:
| Page address | `assets/style.css` loads | `/assets/style.css` loads |
| --- | --- | --- |
| `/` | `/assets/style.css` | `/assets/style.css` |
| `/about/` | `/about/assets/style.css` (missing) | `/assets/style.css` |
| `/about` | `/assets/style.css` | `/assets/style.css` |
So a page with relative links can look right when you test it and lose its
styles when someone links to it with or without the trailing `/`. Links that
start with `/` do not have this problem.
## Add a 404 Page
A request for a path with no file gets a default 404 page. To show your own,
add a `.puter_site_config` file at the top of the folder that points to it:
```js
await puter.fs.write(`${dir}/.puter_site_config`, JSON.stringify({
errors: {
404: { file: '/404.html' },
},
}));
```
The page is sent with a `404` status, so search engines know it is not a real
page. The `.puter_site_config` file itself is never served to visitors.
The site reads this file again at most once a minute, so give a change a minute
before you test it. If the file has a mistake, such as broken JSON, the site
keeps working and shows the default 404 page. See
[Site configuration](/site-config/) for the full format.
For a single-page app, such as one built with React Router, point the 404 rule
at `/index.html` with `"status": 200` instead, so every path loads the app.
@@ -0,0 +1,186 @@
---
title: Handle Errors in a Worker
description: "Learn how to handle errors in a Puter.js serverless worker, so your app can show users what went wrong."
tags: [workers]
order: 41
---
Some requests to your [serverless worker](/Workers/) will fail. A user sends an
empty form, their session has expired, or a service the worker calls is down.
Your app needs to know which of these happened, so it can show the user the
right message.
A worker tells the caller what went wrong with the HTTP status code and the
response body. It answers `400` for bad input, `401` when the user is not signed
in, and `404` when the thing they asked for does not exist. The sections below
show how to return these errors from a worker, and how to read them in your app.
## What Happens When a Handler Throws
If a handler throws, or a promise it awaits rejects, the worker catches it and
answers `500`. The body of that response is the error as text, such as
`SyntaxError: Unexpected end of JSON input`.
That has two problems. The caller cannot tell a bad request apart from a bug in
your code, because both are `500`. And the error text can show details of your
code, or of a service the worker calls, to anyone calling the URL.
The most common cause is reading the body. The `request.json()` method throws
when the body is empty or is not valid JSON:
```js
router.post('/notes', async ({ request }) => {
const { text } = await request.json(); // throws on a bad body: 500
return { text };
});
```
## Return Your Own Errors
Give every error the same shape, so your app only has to read it one way. A
small helper builds the response:
```js
function error(status, message) {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
```
Read the body in a `try` block, then check each field before you use it:
```js
router.post('/notes', async ({ request, user }) => {
if (!user) {
return error(401, 'sign in required');
}
let body;
try {
body = await request.json();
} catch {
return error(400, 'body must be JSON');
}
const { text } = body;
if (typeof text !== 'string' || text.trim().length === 0) {
return error(400, 'text is required');
}
if (text.length > 1000) {
return error(400, 'text must be at most 1000 characters');
}
const note = { id: crypto.randomUUID(), text, at: Date.now() };
await user.puter.kv.set(`notes:${note.id}`, note);
return note;
});
```
Check the type as well as the value. A body like `{ "text": 42 }` or
`{ "text": ["a"] }` is valid JSON, but `text.trim()` would throw on it.
## Check the User's Session
When your app calls the worker with [`puter.workers.exec()`](/Workers/exec/),
the request carries the user's Puter session, and the route gets a `user`. The
route gets a `user` even when the session is wrong or expired, because the
session is not checked until you use it. The first call made with it rejects.
To answer `401` in that case too, look up the user in a `try` block:
```js
async function getCaller(user) {
if (!user) return null;
try {
return await user.puter.getUser();
} catch {
return null;
}
}
router.get('/me', async ({ user }) => {
const caller = await getCaller(user);
if (!caller) {
return error(401, 'sign in required');
}
return { username: caller.username };
});
```
## Catch Errors From Other Services
A call to another service can fail in ways you cannot check up front. Wrap that
call in a `try` block and send the caller a short message instead of the raw
error:
```js
router.post('/summary', async ({ request, user }) => {
const caller = await getCaller(user);
if (!caller) {
return error(401, 'sign in required');
}
let body;
try {
body = await request.json();
} catch {
return error(400, 'body must be JSON');
}
if (typeof body.text !== 'string' || body.text.length === 0) {
return error(400, 'text is required');
}
try {
const reply = await user.puter.ai.chat(`Summarize: ${body.text}`);
return { summary: reply.message.content };
} catch {
return error(502, 'could not summarize right now, try again');
}
});
```
A `502` tells the caller that the worker is fine but a service it depends on is
not, so trying again later may work.
## Return JSON for Unknown Paths
A request to a path that no route matches gets a `404` with a plain-text body,
and so does a request with a method that has no route. To return the same JSON
shape for these too, add a wildcard route for each method you use, at the end of
the file after every other route:
```js
router.get('/*path', async () => error(404, 'not found'));
router.post('/*path', async () => error(404, 'not found'));
```
Routes are tried in the order you register them, so a wildcard registered first
would answer every request.
## Read the Error in Your App
The [`puter.workers.exec()`](/Workers/exec/) method works like `fetch()`. It
resolves for every status code, so a `400` is not thrown. Check `res.ok` and
read the message:
```js
const res = await puter.workers.exec('https://notes-api.puter.work/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: '' }),
});
if (!res.ok) {
const { error } = await res.json(); // 'text is required'
alert(error);
} else {
const note = await res.json();
}
```
It only rejects when there is no response to read, such as when the user is
offline or closes the sign-in window, so keep a `try` block around it for those
cases.
+199
View File
@@ -0,0 +1,199 @@
---
title: Keep an API Key Secret
description: "Learn how to call a third-party API from your app without exposing its API key, by keeping the key in a Puter.js serverless worker."
tags: [workers, kv, auth]
order: 43
---
Many APIs, such as a weather, maps or payments service, give you a secret key
and bill you for every request made with it. If that key is in your app's code,
anyone can open the browser's developer tools, copy it and use it on your bill.
With a [serverless worker](/Workers/), the key stays on the server. Your app
calls the worker, the worker adds the key and calls the API, and only the answer
goes back to the browser.
## Write the Worker
Put the key in the worker file and make one route that calls the API with it.
Replace `api.example.com` and its query parameters with the API you use:
```js
const API_KEY = 'sk_live_your_key_here';
router.get('/forecast', async ({ request }) => {
const city = new URL(request.url).searchParams.get('city');
if (!city) {
return new Response('city is required', { status: 400 });
}
const url = new URL('https://api.example.com/v1/forecast');
url.searchParams.set('city', city);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
return new Response(res.body, {
status: res.status,
headers: { 'Content-Type': res.headers.get('Content-Type') ?? 'text/plain' },
});
});
```
The worker's source file stays private in your Puter account. Calling the
worker's URL only runs its routes and never returns the file, so callers cannot
see the key. Keep the key out of anything you publish yourself, such as a public
Git repository.
## Deploy the Worker
Deploy the worker to get its URL, such as `https://forecast-proxy.puter.work`.
The [Workers deployment guide](/Workers/#deployment) covers each way to deploy.
## Call It From Your App
Call the worker's URL with `fetch()`, the same way you would call the API
directly:
```js
const API = 'https://forecast-proxy.puter.work';
const res = await fetch(`${API}/forecast?city=${encodeURIComponent('Paris')}`);
const forecast = await res.json();
```
Your app's code now has no key in it, and every call to the API goes through
the worker.
## Check What the Caller Sends
The caller only chooses `city`. The API's address and the key are written in
the worker, so a caller cannot use your key to call anything else.
To let the caller choose more, such as the units, read the value and check it
before you add it to the request:
```js
const UNITS = ['metric', 'imperial'];
router.get('/forecast', async ({ request }) => {
const params = new URL(request.url).searchParams;
const city = params.get('city');
if (!city) {
return new Response('city is required', { status: 400 });
}
const units = params.get('units') ?? 'metric';
if (!UNITS.includes(units)) {
return new Response('units must be metric or imperial', { status: 400 });
}
const url = new URL('https://api.example.com/v1/forecast');
url.searchParams.set('city', city);
url.searchParams.set('units', units);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
return new Response(res.body, {
status: res.status,
headers: { 'Content-Type': res.headers.get('Content-Type') ?? 'text/plain' },
});
});
```
## Limit How Often the Key Is Used
Anyone who knows the worker's URL can call it, and each call spends your key.
You can limit calls per visitor or per signed-in user.
To limit each visitor without asking them to sign in, count calls by IP address.
[Add a contact form](/recipes/add-contact-form/#limit-how-often-a-visitor-can-send)
shows a complete worker that does this.
To limit each user, have your users sign in with their Puter account. Call the
worker with [`puter.workers.exec()`](/Workers/exec/) instead of `fetch()`. If the
user is not signed in yet, Puter.js opens the Puter sign-in window first. The
request then carries the user's session, and the route gets a `user` for them.
The route can answer `401` to callers without a `user`, and count each user's
calls.
Here is the complete worker with a limit of 100 calls per user per day. The
count is kept in your own [key-value store](/KV/) with `me.puter`, which is
Puter.js signed in to your account as the worker's owner, so users cannot reset
it:
```js
const API_KEY = 'sk_live_your_key_here';
const DAILY_LIMIT = 100;
async function getCaller(user) {
if (!user) return null;
try {
return await user.puter.getUser();
} catch {
return null;
}
}
async function isOverLimit(uuid) {
const day = new Date().toISOString().slice(0, 10);
const key = `forecast:calls:${uuid}:${day}`;
const count = await me.puter.kv.incr(key);
if (count === 1) {
await me.puter.kv.expire(key, 2 * 24 * 60 * 60);
}
return count > DAILY_LIMIT;
}
router.get('/forecast', async ({ request, user }) => {
const caller = await getCaller(user);
if (!caller) {
return new Response('sign in required', { status: 401 });
}
const city = new URL(request.url).searchParams.get('city');
if (!city) {
return new Response('city is required', { status: 400 });
}
if (await isOverLimit(caller.uuid)) {
return new Response('daily limit reached, try again tomorrow', { status: 429 });
}
const url = new URL('https://api.example.com/v1/forecast');
url.searchParams.set('city', city);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
return new Response(res.body, {
status: res.status,
headers: { 'Content-Type': res.headers.get('Content-Type') ?? 'text/plain' },
});
});
```
The `getCaller()` helper looks the user up, because a request with a made-up
token still gets a `user` and only fails once it is used. Each user gets one
counter per day. The [`incr()`](/KV/incr/) method starts a missing counter at 0,
and [`expire()`](/KV/expire/) deletes it two days later, so your store only
keeps recent counters.
In your app, call the worker with [`puter.workers.exec()`](/Workers/exec/) and
show the message when the limit is reached:
```js
const res = await puter.workers.exec(`${API}/forecast?city=${encodeURIComponent('Paris')}`);
if (res.ok) {
const forecast = await res.json();
} else {
alert(await res.text()); // 'daily limit reached, try again tomorrow'
}
```