mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-12 06:41:55 +00:00
Worker recipes medium
This commit is contained in:
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/).
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user