From dc029b43a8036adfacee206bf4ed69ebe4ba36e4 Mon Sep 17 00:00:00 2001 From: Neal Shah <30693865+ProgrammerIn-wonderland@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:09:38 -0400 Subject: [PATCH] Worker and Subdomain Recipes (#4075) * Worker recipes medium * subdomain recipes medium * refine --------- Co-authored-by: Reynaldi Chernando --- .../src/recipes/add-backend-to-website.md | 96 +++++++++ .../src/recipes/add-website-publishing.md | 39 +++- .../src/recipes/build-a-multi-page-website.md | 138 ++++++++++++ src/docs/src/recipes/handle-worker-errors.md | 186 ++++++++++++++++ src/docs/src/recipes/keep-api-key-secret.md | 199 ++++++++++++++++++ 5 files changed, 648 insertions(+), 10 deletions(-) create mode 100644 src/docs/src/recipes/add-backend-to-website.md create mode 100644 src/docs/src/recipes/build-a-multi-page-website.md create mode 100644 src/docs/src/recipes/handle-worker-errors.md create mode 100644 src/docs/src/recipes/keep-api-key-secret.md 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..5c866898d --- /dev/null +++ b/src/docs/src/recipes/add-backend-to-website.md @@ -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//` 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/). diff --git a/src/docs/src/recipes/add-website-publishing.md b/src/docs/src/recipes/add-website-publishing.md index 1c4cb9577..4d717fb46 100644 --- a/src/docs/src/recipes/add-website-publishing.md +++ b/src/docs/src/recipes/add-website-publishing.md @@ -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 diff --git a/src/docs/src/recipes/build-a-multi-page-website.md b/src/docs/src/recipes/build-a-multi-page-website.md new file mode 100644 index 000000000..08ac9f4f9 --- /dev/null +++ b/src/docs/src/recipes/build-a-multi-page-website.md @@ -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 ` + + + ${title} + + + + + ${body} + +`; +} + +const files = { + 'index.html': page('Home', '

Welcome

'), + 'about/index.html': page('About', '

About me

'), + 'blog/index.html': page('Blog', 'My first post'), + 'blog/first-post/index.html': page('My first post', '

My first post

'), + '404.html': page('Not found', '

This page does not exist

'), + '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. diff --git a/src/docs/src/recipes/handle-worker-errors.md b/src/docs/src/recipes/handle-worker-errors.md new file mode 100644 index 000000000..0622b9850 --- /dev/null +++ b/src/docs/src/recipes/handle-worker-errors.md @@ -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. 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..3077e4d7c --- /dev/null +++ b/src/docs/src/recipes/keep-api-key-secret.md @@ -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' +} +```