diff --git a/src/docs/src/recipes/add-backend-to-website.md b/src/docs/src/recipes/add-backend-to-website.md new file mode 100644 index 000000000..c70c4deda --- /dev/null +++ b/src/docs/src/recipes/add-backend-to-website.md @@ -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//` 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/). diff --git a/src/docs/src/recipes/keep-api-key-secret.md b/src/docs/src/recipes/keep-api-key-secret.md new file mode 100644 index 000000000..ea789e493 --- /dev/null +++ b/src/docs/src/recipes/keep-api-key-secret.md @@ -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. diff --git a/src/docs/src/recipes/return-worker-errors.md b/src/docs/src/recipes/return-worker-errors.md new file mode 100644 index 000000000..32a882dd3 --- /dev/null +++ b/src/docs/src/recipes/return-worker-errors.md @@ -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.