From e4d705368261d55913592caaba36428ffeba957f Mon Sep 17 00:00:00 2001 From: Neal Shah Date: Tue, 29 Sep 2026 17:11:06 -0400 Subject: [PATCH] PUT-1960 worker recipes --- src/docs/src/examples.js | 12 + .../examples/workers-build-an-api.html | 62 +++++ .../workers-deploy-from-code-update.html | 41 ++++ src/docs/src/recipes/workers-build-an-api.md | 217 ++++++++++++++++++ .../src/recipes/workers-deploy-from-code.md | 129 +++++++++++ 5 files changed, 461 insertions(+) create mode 100644 src/docs/src/playground/examples/workers-build-an-api.html create mode 100644 src/docs/src/playground/examples/workers-deploy-from-code-update.html create mode 100644 src/docs/src/recipes/workers-build-an-api.md create mode 100644 src/docs/src/recipes/workers-deploy-from-code.md diff --git a/src/docs/src/examples.js b/src/docs/src/examples.js index 112990cfe..a0afa3879 100644 --- a/src/docs/src/examples.js +++ b/src/docs/src/examples.js @@ -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', + }, ], }, ]; diff --git a/src/docs/src/playground/examples/workers-build-an-api.html b/src/docs/src/playground/examples/workers-build-an-api.html new file mode 100644 index 000000000..e43c2bcf1 --- /dev/null +++ b/src/docs/src/playground/examples/workers-build-an-api.html @@ -0,0 +1,62 @@ + + + +

Notes

+ + + + + + diff --git a/src/docs/src/playground/examples/workers-deploy-from-code-update.html b/src/docs/src/playground/examples/workers-deploy-from-code-update.html new file mode 100644 index 000000000..af54be5d7 --- /dev/null +++ b/src/docs/src/playground/examples/workers-deploy-from-code-update.html @@ -0,0 +1,41 @@ + + + +

App builder: project API

+ + + + + + diff --git a/src/docs/src/recipes/workers-build-an-api.md b/src/docs/src/recipes/workers-build-an-api.md new file mode 100644 index 000000000..ad4fe1fe5 --- /dev/null +++ b/src/docs/src/recipes/workers-build-an-api.md @@ -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/). diff --git a/src/docs/src/recipes/workers-deploy-from-code.md b/src/docs/src/recipes/workers-deploy-from-code.md new file mode 100644 index 000000000..f99ff11af --- /dev/null +++ b/src/docs/src/recipes/workers-deploy-from-code.md @@ -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.