mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-12 23:00:06 +00:00
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:
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/).
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
}
|
||||
```
|
||||
Reference in new issue
Block a user