Worker recipes medium

This commit is contained in:
ProgrammerIn-wonderland committed 2026-10-05 02:49:31 -04:00
1 parent a76800d693
commit 21974a1eec
3 files changed
+516

No files matched your search

@@ -0,0 +1,162 @@
---
title: Add a Backend to Your Website
description: "Learn how to add server-side endpoints to a website hosted on Puter by putting a worker file inside the site, with no separate worker to deploy."
tags: [workers, hosting]
order: 44
---
A [serverless worker](/Workers/) usually lives apart from the website that calls
it. You deploy it on its own, it gets its own `*.puter.work` URL, and you keep
the site and the worker in step by hand.
If your site is [hosted on Puter](/deployments/#deploy-to-puter), you can put
the backend inside the site instead. A [dynamic worker](/Workers/dynamic/) is a
file in the site's `__workers` folder. It goes online with the site, and it
answers on the site's own domain.
## 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 same [`router`](/Workers/router/) API as any other
worker:
```js
// __workers/api.worker.js
router.get('/health', async () => {
return { ok: true };
});
router.post('/scores', async ({ request, user }) => {
if (!user) {
return new Response('sign in required', { status: 401 });
}
const { uuid, username } = await user.puter.getUser();
const { score } = await request.json();
if (typeof score !== 'number') {
return new Response('score must be a number', { status: 400 });
}
await me.puter.kv.set(`api:scores:${uuid}`, { username, score });
return { username, score };
});
```
`me.puter` is your own account as the site's owner, and `user.puter` is the
visitor who called it, the same as in a regular worker.
The name before `.worker.js` can use lowercase letters, numbers, `-` and `_`.
Only files directly inside `__workers` are workers. A file in a subfolder, or
one with any other name, is ignored.
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/health` runs the
`/health` route.
Because the worker is on the same domain as the page, the app can use a path
instead of a full URL:
```js
const res = await puter.workers.exec('/__workers/api/scores', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ score: 120 }),
});
await res.json(); // { username: 'grace', score: 120 }
```
[`puter.workers.exec()`](/Workers/exec/) sends the visitor's session, which is
what gives the route `user`. Routes that do not need a user, such as
`/health`, can be called with a plain `fetch()`.
The path is the same on every site, so the code that calls the worker does
not change if you publish the site under another name.
## Update the Code
To change the worker, save the new code to its file in the site's folder, for
example by deploying the site again. Shortly after, requests run the new code.
A dynamic worker is started when a request needs it, such as the first request
after a change, so that request can take a few seconds longer than the ones
after it.
## Keep Secrets in the Worker
Nothing under `__workers` is ever served as a file. A visitor who opens
`/__workers/api.worker.js` gets a `404`, and so does a visitor who asks for any
other file in that folder. Only the worker's routes answer.
That makes the worker file a safe place for code and values the browser should
not see, such as an API key. See
[Keep an API key secret](/recipes/keep-api-key-secret/).
## Use More Than One Worker
Each `.worker.js` file is a worker of its own, with its own path:
```
__workers/
api.worker.js -> /__workers/api/...
matchmaking.worker.js -> /__workers/matchmaking/...
```
All the workers in one site share the same key-value store and files through
`me.puter`, so they can work together on the same data. That also means one
can overwrite another's keys, so start each worker's keys with its own name,
such as `api:` and `matchmaking:`.
This data belongs to the site's address. If you publish the same files under a
different subdomain, the workers there start with an empty store, and the data
written under the old address stays with it.
## Handle a Worker That Did Not Start
Besides the answers your routes return, a dynamic worker can answer with:
| Status | Meaning |
| --- | --- |
| `404` | There is no worker file with that name directly under `__workers`. |
| `503` | The file exists, but the worker could not be started this time. |
A `503` can pass, so it is worth trying again. A small helper retries a few
times before giving up:
```js
async function callWorker(path, options, attempts = 3) {
for (let i = 1; ; i++) {
const res = await puter.workers.exec(path, options);
if (res.status !== 503 || i === attempts) return res;
await new Promise(resolve => setTimeout(resolve, 1000 * i));
}
}
const res = await callWorker('/__workers/api/health');
```
Only retry requests that are safe to repeat, such as reads, or writes that set
a value rather than add to one.
## Know the Limits
- Dynamic workers answer on `*.puter.site` addresses only, not on custom
domains.
- They do not appear in [`puter.workers.list()`](/Workers/list/) or the
Developer Center, because there is no separate worker to list.
When you need either of these, deploy a regular worker instead. See
[Build an API with a worker](/recipes/build-an-api/).
+172
View File
@@ -0,0 +1,172 @@
---
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 the key is not
visible to callers. Keep it out of anything you publish, though, such as a
public Git repository or a response the worker sends.
## Return a New Response
The route above builds a new `Response` from the API's answer instead of
returning `res` itself. The worker adds a CORS header to every response so your
app can read it, and the headers of a response from `fetch()` cannot be changed.
Unless the API already sent a CORS header, returning `res` as it is makes the
route fail with a `500`.
Copying only the body, the status and the content type also keeps the API's
other headers, such as cookies or account details, away from the browser.
## Only Forward What You Expect
The worker decides which API, which path and which parameters are used. The
caller only fills in `city`. Never take the full URL, the path or the headers
from the request, because the worker would then send your key with any request a
stranger asks for.
If the API takes a fixed set of values, check them before the call:
```js
const UNITS = ['metric', 'imperial'];
const units = new URL(request.url).searchParams.get('units') ?? 'metric';
if (!UNITS.includes(units)) {
return new Response('units must be metric or imperial', { status: 400 });
}
url.searchParams.set('units', units);
```
## Require Sign-In and Limit Use
Anyone who knows the worker's URL can call it, and each call spends your key.
Calling it with [`puter.workers.exec()`](/Workers/exec/) gives the route a
`user`, so it can turn away callers who are not signed in and count how often
each user 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`, 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' },
});
});
```
`getCaller()` 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.
[`puter.kv.incr()`](/KV/incr/) starts a missing counter at 0, and
[`puter.kv.expire()`](/KV/expire/) removes it two days later so old counters do
not pile up.
## 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 with [`puter.workers.exec()`](/Workers/exec/), so the request
carries the signed-in user:
```js
const API = 'https://forecast-proxy.puter.work';
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'
}
```
Your app's code now has no key in it, and every call to the API goes through
the worker.
@@ -0,0 +1,182 @@
---
title: Return Clear Errors from a Worker
description: "Learn how to check the input to a Puter.js serverless worker and answer with the right status code, so your app knows what went wrong."
tags: [workers]
order: 41
---
When something goes wrong in a [serverless worker](/Workers/), the caller should
learn what happened from the response: a `400` when the input is bad, a `401`
when nobody is signed in, a `404` when the thing they asked for is not there.
Your app can then show a useful message instead of a generic failure.
A worker only does part of this on its own. This recipe covers what the
[`router`](/Workers/router/) does when a handler fails, and how to answer with
your own errors instead.
## 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. `request.json()` 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 };
});
```
## Answer With 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
`user` is set whenever the request has a `puter-auth` header, and the header is
not checked until you use it. A request with a wrong or expired token still gets
a `user`, and the first call made with it rejects.
To answer `401` for that case, 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 What You Did Not Expect
A call to another service can still fail in ways you cannot check up front. Wrap
that part 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');
}
});
```
`502` tells the caller the worker itself is fine but something it depends on is
not, so trying again later may work.
## Answer Paths You Do Not Serve
A request to a path no route matches gets a `404` with a plain-text body, and so
does a request with a method you registered no route for. To keep the 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
[`puter.workers.exec()`](/Workers/exec/) 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.