mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-10 22:01:40 +00:00
PUT-1960 worker recipes
This commit is contained in:
1 parent
cd37cf147f
commit
e4d7053682
5 files changed
+461
No files matched your search
@@ -986,6 +986,18 @@ const examples = [
|
||||
slug: 'workers-exec',
|
||||
source: '/playground/examples/workers-exec.html',
|
||||
},
|
||||
{
|
||||
title: 'Build an API',
|
||||
description: 'Deploy a worker whose routes store notes in the calling user\'s own key-value store with user.puter, and call it with puter.workers.exec(). Run and modify this example directly in your browser.',
|
||||
slug: 'workers-build-an-api',
|
||||
source: '/playground/examples/workers-build-an-api.html',
|
||||
},
|
||||
{
|
||||
title: 'Update a worker in place',
|
||||
description: 'Ship new code to a deployed worker at the same URL by overwriting its source file with Puter.js workers API. Run and modify this example directly in your browser.',
|
||||
slug: 'workers-deploy-from-code-update',
|
||||
source: '/playground/examples/workers-deploy-from-code-update.html',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<h3>Notes</h3>
|
||||
<input id="text" placeholder="Write a note">
|
||||
<button id="save">Save note</button>
|
||||
<button id="list">Show my notes</button>
|
||||
<script>
|
||||
// Every note is stored in the calling user's own KV through user.puter,
|
||||
// so each user only ever sees their own notes.
|
||||
const workerCode = `
|
||||
router.post('/notes', async ({ request, user }) => {
|
||||
if (!user) return new Response('sign in required', { status: 401 });
|
||||
|
||||
const { text } = await request.json();
|
||||
if (typeof text !== 'string' || text.length === 0) {
|
||||
return new Response('text is required', { status: 400 });
|
||||
}
|
||||
|
||||
const note = { id: crypto.randomUUID(), text, at: Date.now() };
|
||||
await user.puter.kv.set('notes:' + note.id, note);
|
||||
return note;
|
||||
});
|
||||
|
||||
router.get('/notes', async ({ user }) => {
|
||||
if (!user) return new Response('sign in required', { status: 401 });
|
||||
|
||||
const rows = await user.puter.kv.list('notes:', true);
|
||||
return rows.map(row => row.value);
|
||||
});
|
||||
`;
|
||||
|
||||
let api;
|
||||
|
||||
async function deploy () {
|
||||
await puter.fs.write('notes-api.js', workerCode);
|
||||
const deployment = await puter.workers.create(puter.randName(), 'notes-api.js');
|
||||
puter.print(`Deployed to ${deployment.url}, waiting for it to go live...<br>`);
|
||||
// A new worker takes a few seconds to reach every edge server.
|
||||
await new Promise(resolve => setTimeout(resolve, 10000));
|
||||
return deployment.url;
|
||||
}
|
||||
|
||||
document.getElementById('save').addEventListener('click', async () => {
|
||||
api ??= deploy();
|
||||
// exec() sends the signed-in user, which is what gives the worker user.puter.
|
||||
const res = await puter.workers.exec(`${await api}/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: document.getElementById('text').value }),
|
||||
});
|
||||
puter.print(`${res.status}: ${await res.text()}<br>`);
|
||||
});
|
||||
|
||||
document.getElementById('list').addEventListener('click', async () => {
|
||||
api ??= deploy();
|
||||
const notes = await (await puter.workers.exec(`${await api}/notes`)).json();
|
||||
puter.print(`You have ${notes.length} note(s): ${notes.map(n => n.text).join(', ')}<br>`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<h3>App builder: project API</h3>
|
||||
<input id="greeting" value="Welcome back">
|
||||
<button id="ship">Ship change</button>
|
||||
<button id="call">Call the worker</button>
|
||||
<script>
|
||||
const name = `project-${puter.randName()}`;
|
||||
// The route greets whoever calls it, using their own Puter account.
|
||||
const code = greeting => `
|
||||
router.get('/', async ({ user }) => {
|
||||
if (!user) return new Response('sign in required', { status: 401 });
|
||||
const { username } = await user.puter.getUser();
|
||||
return ${JSON.stringify(greeting)} + ', ' + username;
|
||||
});
|
||||
`;
|
||||
|
||||
const deployed = (async () => {
|
||||
await puter.fs.write(`${name}.js`, code('Hello'));
|
||||
const { url } = await puter.workers.create(name, `${name}.js`);
|
||||
puter.print(`Created ${url}, live within about 30 seconds<br>`);
|
||||
return url;
|
||||
})();
|
||||
|
||||
document.getElementById('ship').addEventListener('click', async () => {
|
||||
await deployed;
|
||||
const info = await puter.workers.get(name);
|
||||
|
||||
// Overwriting the source file redeploys at the same URL.
|
||||
await puter.fs.write(info.file_path, code(document.getElementById('greeting').value));
|
||||
puter.print(`Shipped. ${info.url} serves the new code within about 30 seconds.<br>`);
|
||||
});
|
||||
|
||||
document.getElementById('call').addEventListener('click', async () => {
|
||||
const res = await puter.workers.exec(await deployed);
|
||||
puter.print(`${res.status}: ${await res.text()}<br>`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,217 @@
|
||||
---
|
||||
title: Build an API with a Worker
|
||||
description: "Learn how to write the routes of a serverless worker with Puter.js, reading requests, storing data in the calling user's own account and sending back JSON or errors."
|
||||
tags: [workers, kv, auth]
|
||||
order: 38
|
||||
---
|
||||
|
||||
A [serverless worker](/Workers/) is JavaScript that runs in the cloud instead of
|
||||
in the user's browser. It answers HTTP requests at its own URL, such as
|
||||
`https://notes-api.puter.work`, and you write it as a list of routes, each one a
|
||||
path and the function that handles it.
|
||||
|
||||
When your app calls a worker with
|
||||
[`puter.workers.exec()`](/Workers/exec/), the worker knows which user is calling
|
||||
and gets that user's Puter account as `user.puter`. A route can then read and
|
||||
write the user's own [key-value store](/KV/), files and AI, the same way your
|
||||
app does in the browser. The data stays in the user's account, and the usage is
|
||||
billed to them under the [User-Pays model](/user-pays-model/), so you get a
|
||||
backend without paying for its storage.
|
||||
|
||||
## Define a Route
|
||||
|
||||
The `router` object is available in every worker. Register a route with the
|
||||
method's name and a path, and return what the caller should get back:
|
||||
|
||||
```js
|
||||
router.get('/me', async ({ user }) => {
|
||||
if (!user) {
|
||||
return new Response('sign in required', { status: 401 });
|
||||
}
|
||||
|
||||
const { username } = await user.puter.getUser();
|
||||
return { username };
|
||||
});
|
||||
```
|
||||
|
||||
There is one method per HTTP verb: `router.get()`, `router.post()`,
|
||||
`router.put()`, `router.delete()` and `router.options()`.
|
||||
|
||||
`user` is only there when the request came through
|
||||
[`puter.workers.exec()`](/Workers/exec/), so every route that uses it starts by
|
||||
checking for it.
|
||||
|
||||
## Store Data in the User's Account
|
||||
|
||||
To save something for the caller, write it with `user.puter.kv`. Read the JSON
|
||||
body with `request.json()`:
|
||||
|
||||
```js
|
||||
router.post('/notes', async ({ request, user }) => {
|
||||
if (!user) {
|
||||
return new Response('sign in required', { status: 401 });
|
||||
}
|
||||
|
||||
const { text } = await request.json();
|
||||
if (typeof text !== 'string' || text.length === 0) {
|
||||
return new Response('text is required', { status: 400 });
|
||||
}
|
||||
|
||||
const note = { id: crypto.randomUUID(), text, at: Date.now() };
|
||||
await user.puter.kv.set(`notes:${note.id}`, note);
|
||||
return note;
|
||||
});
|
||||
```
|
||||
|
||||
Each user's notes land in their own store, so one user can never read or
|
||||
overwrite another's, whatever the request body says. These are the same keys
|
||||
your app sees through [`puter.kv`](/KV/) in the browser, so the app can also
|
||||
read a note directly.
|
||||
|
||||
## Read the Request
|
||||
|
||||
Each handler receives one object. Its `request` is a standard
|
||||
[`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request), and its
|
||||
`params` holds the parts of the path you marked with a colon:
|
||||
|
||||
```js
|
||||
router.get('/notes/:id', async ({ params, user }) => {
|
||||
if (!user) {
|
||||
return new Response('sign in required', { status: 401 });
|
||||
}
|
||||
|
||||
const note = await user.puter.kv.get(`notes:${params.id}`);
|
||||
if (!note) {
|
||||
return new Response('note not found', { status: 404 });
|
||||
}
|
||||
|
||||
return note;
|
||||
});
|
||||
```
|
||||
|
||||
Captured values are always strings, so convert them yourself when you need a
|
||||
number.
|
||||
|
||||
Query strings are not part of the path. To read one, parse the request URL:
|
||||
|
||||
```js
|
||||
router.get('/notes', async ({ request, user }) => {
|
||||
if (!user) {
|
||||
return new Response('sign in required', { status: 401 });
|
||||
}
|
||||
|
||||
const q = new URL(request.url).searchParams.get('q') ?? '';
|
||||
const rows = await user.puter.kv.list('notes:', true);
|
||||
|
||||
return rows
|
||||
.map(row => row.value)
|
||||
.filter(note => note.text.includes(q));
|
||||
});
|
||||
```
|
||||
|
||||
## Send a Response
|
||||
|
||||
A handler that returns a plain object or array is sent as JSON, and one that
|
||||
returns a string is sent as text. To choose the status code or the headers,
|
||||
return a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response),
|
||||
as the `401`, `400` and `404` answers above do.
|
||||
|
||||
The worker adds CORS headers to every response, so your app can call it from
|
||||
any origin without extra setup.
|
||||
|
||||
## Handle Unknown Paths
|
||||
|
||||
A wildcard route matches the rest of the path. Register one last, so it only
|
||||
runs when no other route matched, and use it to answer with a `404`:
|
||||
|
||||
```js
|
||||
router.get('/*path', async ({ params }) => {
|
||||
return new Response(`no route for /${params.path}`, { status: 404 });
|
||||
});
|
||||
```
|
||||
|
||||
The wildcard needs a name, such as `*path`. A bare `*` is read as a literal
|
||||
character and does not match anything else.
|
||||
|
||||
## Routes Without a User
|
||||
|
||||
A worker is also an ordinary HTTP endpoint, so it can serve callers that have
|
||||
never heard of Puter: a webhook from another service, a `curl` in a script, or a
|
||||
page that doesn't load Puter.js. These routes never touch `user`.
|
||||
|
||||
A route can do its work and answer, with nothing to store:
|
||||
|
||||
```js
|
||||
router.get('/slugify', async ({ request }) => {
|
||||
const text = new URL(request.url).searchParams.get('text') ?? '';
|
||||
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
});
|
||||
```
|
||||
|
||||
Any HTTP client can call it:
|
||||
|
||||
```sh
|
||||
curl "https://notes-api.puter.work/slugify?text=Hello%20World"
|
||||
# hello-world
|
||||
```
|
||||
|
||||
To keep what an outside service sends you, store it in your own account with
|
||||
`me.puter`, which is available to every request whoever made it:
|
||||
|
||||
```js
|
||||
router.post('/webhooks/payments', async ({ request }) => {
|
||||
const event = await request.json();
|
||||
|
||||
await me.puter.kv.set(`payments:${event.id}`, event);
|
||||
return { received: true };
|
||||
});
|
||||
```
|
||||
|
||||
A worker can also call other APIs with `fetch()`, which lets it reshape a
|
||||
third-party response before your app sees it:
|
||||
|
||||
```js
|
||||
router.get('/weather/:city', async ({ params }) => {
|
||||
const res = await fetch(`https://wttr.in/${encodeURIComponent(params.city)}?format=j1`);
|
||||
const data = await res.json();
|
||||
|
||||
return { city: params.city, tempC: data.current_condition[0].temp_C };
|
||||
});
|
||||
```
|
||||
|
||||
Anyone who knows the URL can call a route like these, so don't put anything
|
||||
behind one that should be private.
|
||||
|
||||
## Call It From Your App
|
||||
|
||||
Once the worker is [deployed](/Workers/#deployment), call it with
|
||||
[`puter.workers.exec()`](/Workers/exec/). It takes the same arguments as
|
||||
`fetch()` and adds the signed-in user's session, which is what gives the worker
|
||||
`user.puter`:
|
||||
|
||||
```js
|
||||
const API = 'https://notes-api.puter.work';
|
||||
|
||||
const res = await puter.workers.exec(`${API}/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: 'Buy milk' }),
|
||||
});
|
||||
const note = await res.json(); // { id: '5f0c…', text: 'Buy milk', at: 1788827048741 }
|
||||
|
||||
const notes = await (await puter.workers.exec(`${API}/notes?q=milk`)).json();
|
||||
```
|
||||
|
||||
A plain `fetch()` of the same URL carries no session, so `user` is undefined
|
||||
and the notes routes answer `401`. It works for
|
||||
[routes without a user](#routes-without-a-user), which is how
|
||||
callers without Puter.js reach them.
|
||||
|
||||
To keep data that every user reads and writes together, such as a leaderboard,
|
||||
store it with `me.puter` instead. See
|
||||
[Store server-side data](/recipes/store-server-side-data/).
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
title: Deploy a Worker from Your App
|
||||
description: "Learn how to create, update, list and delete serverless workers from your own code with Puter.js, instead of publishing them by hand."
|
||||
tags: [workers]
|
||||
order: 42
|
||||
---
|
||||
|
||||
You can publish a [serverless worker](/Workers/) by hand from puter.com. You
|
||||
can also do it from code, with the [Workers API](/Workers/#workers-api). Your
|
||||
app then creates a worker, updates its code and removes it, the same way it
|
||||
writes files.
|
||||
|
||||
This lets you build tools that ship backends for their users, such as an app
|
||||
builder that gives each project its own API.
|
||||
|
||||
## Create a Worker
|
||||
|
||||
A worker is deployed from a JavaScript file in your Puter account. Write the
|
||||
code with [`puter.fs.write()`](/FS/write/), then pass the file to
|
||||
[`puter.workers.create()`](/Workers/create/) along with a name:
|
||||
|
||||
```js
|
||||
await puter.fs.write('notes-api.js', `
|
||||
router.get('/me', async ({ user }) => {
|
||||
const { username } = await user.puter.getUser();
|
||||
return { username };
|
||||
});
|
||||
`);
|
||||
|
||||
const deployment = await puter.workers.create('notes-api', 'notes-api.js');
|
||||
|
||||
deployment.url; // 'https://notes-api.puter.work'
|
||||
```
|
||||
|
||||
Give it 5 to 30 seconds before the first request, while the worker spreads to
|
||||
every edge server. Then call it with
|
||||
[`puter.workers.exec()`](/Workers/exec/), which sends the signed-in user along
|
||||
so the route can use `user.puter`:
|
||||
|
||||
```js
|
||||
const res = await puter.workers.exec(`${deployment.url}/me`);
|
||||
await res.json(); // { username: 'grace' }
|
||||
```
|
||||
|
||||
[Build an API with a worker](/recipes/workers-build-an-api/) covers what to put
|
||||
in the routes.
|
||||
|
||||
Worker names are global, like subdomains, and are stored in lowercase. A name
|
||||
can use letters, numbers, hyphens and underscores. When another account already
|
||||
has the name, `create()` rejects, so pick another one or use
|
||||
[`puter.randName()`](/Utils/randName/). The account deploying the worker needs a
|
||||
verified email address.
|
||||
|
||||
## Update Its Code
|
||||
|
||||
A worker keeps its name and URL for as long as it exists. To change what it
|
||||
runs, overwrite its source file. [`puter.workers.get()`](/Workers/get/) tells
|
||||
you where that file is:
|
||||
|
||||
```js
|
||||
const info = await puter.workers.get('notes-api');
|
||||
|
||||
await puter.fs.write(info.file_path, updatedCode);
|
||||
```
|
||||
|
||||
Writing the file redeploys the worker at the same URL, so anything already
|
||||
calling it keeps working.
|
||||
|
||||
Don't create a new worker with a new name to ship a change. The old one stays
|
||||
online at its old URL, and every caller has to be pointed at the new one.
|
||||
|
||||
## Find Your Workers
|
||||
|
||||
To show the workers in your account, call
|
||||
[`puter.workers.list()`](/Workers/list/):
|
||||
|
||||
```js
|
||||
const workers = await puter.workers.list();
|
||||
|
||||
for (const worker of workers) {
|
||||
console.log(worker.name, worker.url);
|
||||
}
|
||||
```
|
||||
|
||||
To look up one worker by name, use [`puter.workers.get()`](/Workers/get/). It
|
||||
resolves to `undefined` when there is no worker with that name, which makes it
|
||||
a quick existence check before a deploy:
|
||||
|
||||
```js
|
||||
const existing = await puter.workers.get('notes-api');
|
||||
|
||||
if (!existing) {
|
||||
await puter.workers.create('notes-api', 'notes-api.js');
|
||||
}
|
||||
```
|
||||
|
||||
## Keep Workers Apart
|
||||
|
||||
Inside a worker, [`me.puter`](/Workers/router/#integration-with-puter-js)
|
||||
reaches the key-value store and files of the app the worker runs as. When your
|
||||
app creates workers, they run as your app by default, so they all share that
|
||||
data with each other and with your app.
|
||||
|
||||
To give each worker its own data, pass `sandbox: true`:
|
||||
|
||||
```js
|
||||
await puter.workers.create('project-alpha-api', 'alpha.js', { sandbox: true });
|
||||
await puter.workers.create('project-beta-api', 'beta.js', { sandbox: true });
|
||||
```
|
||||
|
||||
Each worker now runs as its own app, so a key one of them writes is not visible
|
||||
to the other. Decide this before the worker stores anything, because changing
|
||||
it later does not move data that was already written.
|
||||
|
||||
## Delete a Worker
|
||||
|
||||
To take a worker offline, call [`puter.workers.delete()`](/Workers/delete/)
|
||||
with its name:
|
||||
|
||||
```js
|
||||
await puter.workers.delete('notes-api');
|
||||
```
|
||||
|
||||
Its URL stops answering, and the name is free to use again. The source file
|
||||
stays in your account.
|
||||
|
||||
The link also works the other way. Deleting a worker's source file with
|
||||
[`puter.fs.delete()`](/FS/delete/) deletes the worker too, so keep the file for
|
||||
as long as the worker should stay online.
|
||||
Reference in new issue
Block a user