mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e76e8f6921 |
No files matched your search
@@ -0,0 +1,202 @@
|
||||
---
|
||||
title: Add an AI Assistant
|
||||
description: "Learn how to add an AI chat assistant to your app with Puter.js, with its instructions kept in a serverless worker and each user's chats saved to their own account."
|
||||
tags: [workers, ai, kv, auth]
|
||||
order: 63
|
||||
---
|
||||
|
||||
An AI assistant in your app is mostly instructions. A system prompt tells the
|
||||
model what your app is, what to answer and what to refuse. If that prompt lives
|
||||
in the browser, anyone can read it, change it, and use your assistant for
|
||||
something else.
|
||||
|
||||
A [serverless worker](/Workers/) keeps the prompt on the server. The browser
|
||||
only sends the user's message. The worker adds the instructions and the earlier
|
||||
messages, asks the model, and saves the conversation.
|
||||
|
||||
The model call goes through `user.puter`, the Puter account of the user who is
|
||||
chatting, so each user pays for their own messages under the
|
||||
[User-Pays model](/user-pays-model/). Their chats are saved in their own
|
||||
[key-value store](/KV/), not yours.
|
||||
|
||||
## Keep the Instructions in the Worker
|
||||
|
||||
Put the system prompt and the model settings at the top of the worker file. None
|
||||
of it is sent to the browser:
|
||||
|
||||
```js
|
||||
// assistant.js
|
||||
const SYSTEM = `You are the help assistant for Acme Notes, a note-taking app.
|
||||
Only answer questions about using Acme Notes. Keep answers under 120 words.`;
|
||||
|
||||
const MODEL = 'gpt-5.5';
|
||||
const MAX_MESSAGES = 20; // how much earlier conversation the model sees
|
||||
const MAX_LENGTH = 2000; // longest message a user may send
|
||||
|
||||
function error(status, message) {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
To change how the assistant behaves, edit this file. Every user gets the new
|
||||
instructions on their next message, with no app update.
|
||||
|
||||
## Read the Message
|
||||
|
||||
The route needs a signed-in user, because the model call is billed to them. Your
|
||||
app calls it with [`puter.workers.exec()`](/Workers/exec/), which sends the
|
||||
user's session along as `user`:
|
||||
|
||||
```js
|
||||
router.post('/chat', 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 { message } = body;
|
||||
if (typeof message !== 'string' || message.trim().length === 0) {
|
||||
return error(400, 'message is required');
|
||||
}
|
||||
if (message.length > MAX_LENGTH) {
|
||||
return error(400, `message must be at most ${MAX_LENGTH} characters`);
|
||||
}
|
||||
const chatId = typeof body.chatId === 'string' ? body.chatId : crypto.randomUUID();
|
||||
|
||||
// ...load the chat and ask the model, shown in the next sections
|
||||
});
|
||||
```
|
||||
|
||||
A request without a `chatId` starts a new chat. See
|
||||
[Handle errors in a worker](/recipes/handle-worker-errors/) for the `error()`
|
||||
helper and the checks.
|
||||
|
||||
## Load the Earlier Messages
|
||||
|
||||
Each chat is one entry in the user's key-value store, under `assistant:` and the
|
||||
chat's ID. Read it with `user.puter.kv`:
|
||||
|
||||
```js
|
||||
const key = `assistant:${chatId}`;
|
||||
const chat = (await user.puter.kv.get(key)) ?? { title: message.slice(0, 60), messages: [] };
|
||||
```
|
||||
|
||||
Because it is `user.puter`, the key is in that user's account. One user can
|
||||
never load another user's chat, even with the right `chatId`.
|
||||
|
||||
## Ask the Model
|
||||
|
||||
Send the system prompt first, then the last few messages, then the new one, with
|
||||
[`puter.ai.chat()`](/AI/chat/):
|
||||
|
||||
```js
|
||||
chat.messages.push({ role: 'user', content: message });
|
||||
|
||||
let reply;
|
||||
try {
|
||||
const response = await user.puter.ai.chat(
|
||||
[
|
||||
{ role: 'system', content: SYSTEM },
|
||||
...chat.messages.slice(-MAX_MESSAGES),
|
||||
],
|
||||
{ model: MODEL, normalize: true },
|
||||
);
|
||||
reply = response.message.content;
|
||||
} catch {
|
||||
return error(502, 'the assistant could not answer right now, try again');
|
||||
}
|
||||
```
|
||||
|
||||
`normalize: true` makes `message.content` a plain string whichever model you
|
||||
pick, so changing `MODEL` later doesn't change this code.
|
||||
|
||||
Only the last `MAX_MESSAGES` are sent, so a long chat doesn't make every new
|
||||
message slower and more expensive. The system prompt is always added on top,
|
||||
which is why it never falls out of the conversation.
|
||||
|
||||
## Save the Chat
|
||||
|
||||
Add the reply and write the chat back, then return the reply and the chat's ID:
|
||||
|
||||
```js
|
||||
chat.messages.push({ role: 'assistant', content: reply });
|
||||
chat.updatedAt = Date.now();
|
||||
await user.puter.kv.set(key, chat);
|
||||
|
||||
return { chatId, reply };
|
||||
```
|
||||
|
||||
Put these last three sections together, in order, inside the `/chat` route.
|
||||
|
||||
A key-value entry has a size limit, and a chat grows with every message. For
|
||||
chats that run to hundreds of messages, see
|
||||
[Split a large value across keys](/recipes/split-a-large-value/).
|
||||
|
||||
## Deploy the Worker
|
||||
|
||||
Save `assistant.js` to your Puter account and create the worker once, which
|
||||
gives it an address such as `https://acme-assistant.puter.work`. See
|
||||
[deployment](/Workers/#deployment) for the steps.
|
||||
|
||||
## Chat From Your App
|
||||
|
||||
Call the worker with [`puter.workers.exec()`](/Workers/exec/). It takes the same
|
||||
arguments as `fetch()`, and asks the user to sign in first if they haven't:
|
||||
|
||||
```js
|
||||
const API = 'https://acme-assistant.puter.work';
|
||||
let chatId = null;
|
||||
|
||||
async function send(message) {
|
||||
const res = await puter.workers.exec(`${API}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chatId, message }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
chatId = data.chatId; // keep talking in the same chat
|
||||
return data.reply;
|
||||
}
|
||||
|
||||
await send('How do I share a note?');
|
||||
await send('And can I undo that?'); // the model sees the first question too
|
||||
```
|
||||
|
||||
## List Past Chats
|
||||
|
||||
When a worker is called from your app, `user.puter.kv` is the same key-value
|
||||
store your app reads with [`puter.kv`](/KV/). So the app can list the user's
|
||||
chats directly, without a worker route:
|
||||
|
||||
```js
|
||||
const chats = await puter.kv.list('assistant:', true);
|
||||
|
||||
const sidebar = chats
|
||||
.map(row => ({ chatId: row.key.slice('assistant:'.length), ...row.value }))
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
```
|
||||
|
||||
Set `chatId` to one of these to carry on that chat, and delete one with
|
||||
[`puter.kv.del()`](/KV/del/).
|
||||
|
||||
## Notes
|
||||
|
||||
- Saved chats belong to the user, and they can edit them, for example to change
|
||||
what the assistant "said" earlier. That only changes their own chat. The system
|
||||
prompt is always added by the worker, so they can't remove it.
|
||||
- To pay for the model yourself instead, call `me.puter.ai.chat()`. Every
|
||||
message is then billed to you, so add a per-user limit like the one in
|
||||
[Keep an API key secret](/recipes/keep-api-key-secret/).
|
||||
@@ -0,0 +1,238 @@
|
||||
---
|
||||
title: Build a Public Upload Gallery
|
||||
description: "Learn how to build a gallery where users post images from your app with Puter.js. Each image is hosted from the user's own account, and a serverless worker keeps the shared list."
|
||||
tags: [workers, hosting, fs, kv, auth]
|
||||
order: 59
|
||||
---
|
||||
|
||||
A gallery where users post images needs three things: somewhere to keep each
|
||||
file, a public link to show it, and one list of every post that all users can
|
||||
see. Usually that means a storage bucket, a CDN and a database, all paid for by
|
||||
you.
|
||||
|
||||
With Puter, each user keeps their own images. Your app writes the image to the
|
||||
user's account and [hosts it](/Hosting/) there, so the user pays for its storage
|
||||
and for the traffic when others view it, under the
|
||||
[User-Pays model](/user-pays-model/). The only thing your app shares is the list
|
||||
of links. A [serverless worker](/Workers/) keeps that list in your
|
||||
[key-value store](/KV/) and checks every link before adding it.
|
||||
|
||||
## Host the User's Images
|
||||
|
||||
Each user gets one site for their gallery images, created the first time they
|
||||
post. Save its name with [`puter.kv.set()`](/KV/set/) so the next post reuses
|
||||
it:
|
||||
|
||||
```js
|
||||
async function getGallerySite() {
|
||||
let site = await puter.kv.get('gallery:site');
|
||||
if (!site) {
|
||||
site = (await puter.hosting.create(puter.randName(), 'gallery')).subdomain;
|
||||
await puter.kv.set('gallery:site', site);
|
||||
}
|
||||
return site;
|
||||
}
|
||||
```
|
||||
|
||||
`gallery` is a relative path, so it is a folder inside your app's own folder in
|
||||
the user's account. Nothing else the user has is put online.
|
||||
[`puter.randName()`](/Utils/randName/) picks a site name that is unlikely to be
|
||||
taken. Creating a site needs a Puter account with a verified email address.
|
||||
|
||||
## Upload an Image
|
||||
|
||||
Write the file into the hosted folder with [`puter.fs.write()`](/FS/write/). It
|
||||
is online as soon as the write finishes:
|
||||
|
||||
```js
|
||||
const TYPES = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
};
|
||||
|
||||
async function uploadImage(file) {
|
||||
const ext = TYPES[file.type];
|
||||
if (!ext) {
|
||||
throw new Error('only PNG, JPEG, WebP and GIF images are allowed');
|
||||
}
|
||||
|
||||
const name = `${crypto.randomUUID()}.${ext}`;
|
||||
await puter.fs.write(`gallery/${name}`, file, { createMissingParents: true });
|
||||
|
||||
const site = await getGallerySite();
|
||||
return `https://${site}.puter.site/${name}`;
|
||||
}
|
||||
```
|
||||
|
||||
The file is written before `getGallerySite()` runs, because a site can only
|
||||
point at a folder that exists. `createMissingParents` creates the `gallery`
|
||||
folder on the first upload.
|
||||
|
||||
The file gets a name your app picks, and its extension comes from the `TYPES`
|
||||
table. The site serves each file by its extension, so an upload can't turn into
|
||||
an HTML page. SVG is left out for the same reason, since an SVG file can run
|
||||
scripts.
|
||||
|
||||
## Add the Link to the Gallery
|
||||
|
||||
The worker owns the shared list. Your app only sends it the link. Since anyone
|
||||
can call the worker, it doesn't trust the link. It makes two checks before
|
||||
adding it:
|
||||
|
||||
1. **The site belongs to the caller.** [`puter.hosting.get()`](/Hosting/get/)
|
||||
on `user.puter` rejects for a site the user doesn't own. Because the user
|
||||
calls the worker from your app, it also rejects sites your app didn't create.
|
||||
2. **The link is an image that is online.** A `HEAD` request returns the file's
|
||||
type without downloading it.
|
||||
|
||||
```js
|
||||
// gallery.js
|
||||
const TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
|
||||
const HOST_SUFFIX = '.puter.site';
|
||||
|
||||
function error(status, message) {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
router.post('/images', async ({ request, user }) => {
|
||||
if (!user) {
|
||||
return error(401, 'sign in required');
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL((await request.json()).url);
|
||||
} catch {
|
||||
return error(400, 'url is required');
|
||||
}
|
||||
|
||||
const site = url.hostname.slice(0, -HOST_SUFFIX.length);
|
||||
if (url.protocol !== 'https:' || !url.hostname.endsWith(HOST_SUFFIX) || site.includes('.')) {
|
||||
return error(400, 'url must be a Puter site');
|
||||
}
|
||||
|
||||
try {
|
||||
await user.puter.hosting.get(site);
|
||||
} catch {
|
||||
return error(403, 'that site is not yours');
|
||||
}
|
||||
|
||||
const res = await fetch(url, { method: 'HEAD' });
|
||||
if (!res.ok || !TYPES.includes(res.headers.get('Content-Type'))) {
|
||||
return error(400, 'url must be an image that is online');
|
||||
}
|
||||
|
||||
const { username } = await user.puter.getUser();
|
||||
const image = { id: crypto.randomUUID(), url: url.href, username, at: Date.now() };
|
||||
await me.puter.kv.set(`gallery:${image.id}`, image);
|
||||
return image;
|
||||
});
|
||||
```
|
||||
|
||||
The entry is written with `me.puter`, which is your own account, so every user
|
||||
adds to the same list. It holds only the link and who posted it, never the image.
|
||||
|
||||
See [Handle errors in a worker](/recipes/handle-worker-errors/) for more on the
|
||||
`error()` helper.
|
||||
|
||||
## List the Images
|
||||
|
||||
Anyone can read the gallery, so this route doesn't need a `user`:
|
||||
|
||||
```js
|
||||
router.get('/images', async () => {
|
||||
const rows = await me.puter.kv.list('gallery:', true);
|
||||
return rows
|
||||
.map(row => row.value)
|
||||
.sort((a, b) => b.at - a.at);
|
||||
});
|
||||
```
|
||||
|
||||
For a gallery with thousands of images, read one page at a time. See
|
||||
[Store a large collection](/recipes/store-large-collection/).
|
||||
|
||||
## Remove an Image
|
||||
|
||||
Users can take their own images out of the gallery, and you can take out any of
|
||||
them:
|
||||
|
||||
```js
|
||||
const OWNER = 'grace';
|
||||
|
||||
router.delete('/images/:id', async ({ params, user }) => {
|
||||
if (!user) {
|
||||
return error(401, 'sign in required');
|
||||
}
|
||||
|
||||
const image = await me.puter.kv.get(`gallery:${params.id}`);
|
||||
if (!image) {
|
||||
return error(404, 'not found');
|
||||
}
|
||||
|
||||
const { username } = await user.puter.getUser();
|
||||
if (username !== image.username && username !== OWNER) {
|
||||
return error(403, 'you can only remove your own images');
|
||||
}
|
||||
|
||||
await me.puter.kv.del(`gallery:${params.id}`);
|
||||
return { removed: params.id };
|
||||
});
|
||||
```
|
||||
|
||||
This removes the entry, not the file, which is in the user's account. To delete
|
||||
the file too, the app calls [`puter.fs.delete()`](/FS/delete/) on its path after
|
||||
removing the entry, and the link stops working.
|
||||
|
||||
## Deploy the Worker
|
||||
|
||||
Save `gallery.js` to your Puter account and create the worker once, which gives
|
||||
it an address such as `https://gallery-api.puter.work`. See
|
||||
[deployment](/Workers/#deployment) for the steps.
|
||||
|
||||
## Put It Together in Your App
|
||||
|
||||
Upload the image, then send its link to the worker with
|
||||
[`puter.workers.exec()`](/Workers/exec/). It works like `fetch()`, sends the
|
||||
user's session along as `user`, and asks the user to sign in first if they
|
||||
haven't:
|
||||
|
||||
```js
|
||||
const API = 'https://gallery-api.puter.work';
|
||||
|
||||
async function postImage(file) {
|
||||
const url = await uploadImage(file);
|
||||
|
||||
const res = await puter.workers.exec(`${API}/images`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error((await res.json()).error);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function loadGallery() {
|
||||
return await (await fetch(`${API}/images`)).json();
|
||||
// [ { id: '7f3c…', url: 'https://quiet-river-4821.puter.site/1b9e….png',
|
||||
// username: 'grace', at: 1788827048741 }, … ]
|
||||
}
|
||||
```
|
||||
|
||||
Reading the gallery doesn't need a session, so a plain `fetch()` is enough there.
|
||||
Show each image with an `<img>` whose `src` is its `url`.
|
||||
|
||||
## Notes
|
||||
|
||||
- You pay only for the small list entries. Anyone signed in can still add them,
|
||||
so limit how often one user can post. See
|
||||
[Keep an API key secret](/recipes/keep-api-key-secret/) for a per-user limit.
|
||||
- The image stays under the user's control. If they delete the file or the
|
||||
site, the link stops working. Hide images that fail to load, or check the
|
||||
links again from time to time.
|
||||
@@ -65,7 +65,9 @@ await puter.hosting.update('grace-portfolio', 'my-site-v2');
|
||||
|
||||
This is a clean way to release a new version. Build it in a fresh folder while
|
||||
the old one keeps serving, then switch in one call. To roll back, point the
|
||||
site at the old folder again.
|
||||
site at the old folder again. See
|
||||
[Preview and roll back a site](/recipes/preview-and-roll-back-a-site/) for the
|
||||
full flow.
|
||||
|
||||
## Delete a Site
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
---
|
||||
title: Preview and Roll Back a Site
|
||||
description: "Learn how to let users preview a new version of their website before it goes live with Puter.js, and roll back to an older version in one click."
|
||||
tags: [hosting, fs, kv]
|
||||
order: 57
|
||||
---
|
||||
|
||||
When your app [publishes websites](/recipes/add-website-publishing/) for its
|
||||
users, every edit goes live the moment it is published. A typo, a broken link or
|
||||
a missing image is in front of every visitor until the user fixes it.
|
||||
|
||||
A safer flow has three steps. Each version is saved to its own folder. The user
|
||||
first sees it on a separate preview address. Then they make it live, and they
|
||||
can go back to an older version if something is wrong. The
|
||||
[hosting API](/Hosting/) makes each step one call, because a site is just a name
|
||||
that points at a folder.
|
||||
|
||||
The examples below use two sites per project: `grace-portfolio` is the live
|
||||
site, and `grace-portfolio-preview` is the preview.
|
||||
|
||||
## Save Each Version to Its Own Folder
|
||||
|
||||
Never write over the folder the live site serves. Write each version to a new
|
||||
folder instead, numbered with [`puter.kv.incr()`](/KV/incr/):
|
||||
|
||||
```js
|
||||
async function saveVersion(projectId, files) {
|
||||
const n = await puter.kv.incr(`site:${projectId}:lastVersion`);
|
||||
const dir = `site-versions/${projectId}/v${n}`;
|
||||
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
await puter.fs.write(`${dir}/${path}`, content, { createMissingParents: true });
|
||||
}
|
||||
return { n, dir };
|
||||
}
|
||||
|
||||
const version = await saveVersion('portfolio', {
|
||||
'index.html': '<h1>Grace Hopper</h1><img src="images/me.jpg">',
|
||||
'images/me.jpg': photoBlob,
|
||||
});
|
||||
// { n: 4, dir: 'site-versions/portfolio/v4' }
|
||||
```
|
||||
|
||||
[`puter.kv.incr()`](/KV/incr/) adds 1 on the server and returns the new
|
||||
number, so two saves at the same time never get the same folder.
|
||||
`createMissingParents` creates `v4` and `v4/images` as part of the write.
|
||||
|
||||
## Show a Preview
|
||||
|
||||
Point the preview site at the new folder. The first time, there is no preview
|
||||
site yet, so create it with [`puter.hosting.create()`](/Hosting/create/). After
|
||||
that, move it with [`puter.hosting.update()`](/Hosting/update/):
|
||||
|
||||
```js
|
||||
async function pointSite(name, dir) {
|
||||
try {
|
||||
return await puter.hosting.update(name, dir);
|
||||
} catch {
|
||||
return await puter.hosting.create(name, dir);
|
||||
}
|
||||
}
|
||||
|
||||
await pointSite('grace-portfolio-preview', version.dir);
|
||||
// https://grace-portfolio-preview.puter.site now shows version 4
|
||||
```
|
||||
|
||||
`update()` rejects when the site does not exist yet, which is what sends the
|
||||
first call to `create()`. Show the user a link to the preview address so they
|
||||
can click through it before anything changes for their visitors.
|
||||
|
||||
## Make It Live
|
||||
|
||||
When the user is happy, point the live site at the same folder. Nothing is
|
||||
uploaded again, because the files are already in place:
|
||||
|
||||
```js
|
||||
async function publish(projectId, version) {
|
||||
await pointSite('grace-portfolio', version.dir);
|
||||
|
||||
const history = (await puter.kv.get(`site:${projectId}:history`)) ?? [];
|
||||
history.push({ n: version.n, dir: version.dir, at: Date.now() });
|
||||
await puter.kv.set(`site:${projectId}:history`, history);
|
||||
}
|
||||
|
||||
await publish('portfolio', version);
|
||||
```
|
||||
|
||||
The live address stays the same. Visitors get the new version on their next page
|
||||
load, and nobody ever sees a half-written folder.
|
||||
|
||||
The history is a small list of every version that has been live, kept in one
|
||||
[key-value](/KV/) entry. See [Store a small list](/recipes/store-small-list/)
|
||||
for when one entry is enough.
|
||||
|
||||
## Roll Back
|
||||
|
||||
To undo a release, point the live site at the version before it:
|
||||
|
||||
```js
|
||||
async function rollBack(projectId) {
|
||||
const history = (await puter.kv.get(`site:${projectId}:history`)) ?? [];
|
||||
if (history.length < 2) {
|
||||
throw new Error('there is no older version to go back to');
|
||||
}
|
||||
|
||||
history.pop();
|
||||
const previous = history[history.length - 1];
|
||||
await puter.hosting.update('grace-portfolio', previous.dir);
|
||||
await puter.kv.set(`site:${projectId}:history`, history);
|
||||
return previous;
|
||||
}
|
||||
```
|
||||
|
||||
The bad version's folder is still there. Only the site stops pointing at it, so
|
||||
the user can preview it again, fix it and publish once more. To jump to any
|
||||
older version instead of just the last one, call
|
||||
[`puter.hosting.update()`](/Hosting/update/) with that version's `dir`.
|
||||
|
||||
## Delete Old Versions
|
||||
|
||||
Every version is a full copy of the site, so delete old ones from time to time.
|
||||
Keep the last few that have been live, and never delete the one the live or
|
||||
preview site points at:
|
||||
|
||||
```js
|
||||
async function deleteOldVersions(projectId, keep = 5) {
|
||||
const history = (await puter.kv.get(`site:${projectId}:history`)) ?? [];
|
||||
const lastVersion = await puter.kv.get(`site:${projectId}:lastVersion`);
|
||||
|
||||
const kept = new Set(history.slice(-keep).map(v => v.n));
|
||||
kept.add(lastVersion); // the version the preview shows, which may not be live yet
|
||||
|
||||
for (let n = 1; n <= lastVersion; n++) {
|
||||
if (!kept.has(n)) {
|
||||
await puter.fs.delete(`site-versions/${projectId}/v${n}`).catch(() => {});
|
||||
}
|
||||
}
|
||||
await puter.kv.set(`site:${projectId}:history`, history.filter(v => kept.has(v.n)));
|
||||
}
|
||||
```
|
||||
|
||||
[`puter.fs.delete()`](/FS/delete/) removes a folder with everything in it. The
|
||||
`catch` skips folders that were already deleted on an earlier run.
|
||||
|
||||
## Notes
|
||||
|
||||
- Both sites belong to the user and count toward their sites. When your code
|
||||
runs as a Puter app, it can only change sites that app created. See
|
||||
[Manage websites](/recipes/manage-websites/).
|
||||
- The preview address is public, just harder to guess. Don't put anything on it
|
||||
that must stay private.
|
||||
- To take the preview offline after publishing, call
|
||||
[`puter.hosting.delete()`](/Hosting/delete/) with its name. The version's
|
||||
folder is kept.
|
||||
Reference in new issue
Block a user