Events

Websites Puter Apps Node.js Workers

The Events API is in beta. Event shapes, limits, and behavior may change between releases.

The Events API tells your app when a user's data changes. Subscribe to a file, a folder, a path that doesn't exist yet, a key in your app's key-value store, key-value data another user shared with you, or the user's notifications, and your handler runs each time it changes. You don't poll and you don't run a server.

There are two kinds of subscription:

  • Session subscriptions (onLocal()) belong to the open page and end when it closes. Use them to keep a UI in sync.
  • Persistent subscriptions (onPersistent()) are stored on the account and keep running while your app is closed. Each change runs a handler, either inline code you pass in the call or one your app published. Use them for background work, such as processing uploads as they arrive.

What you watch is written as a subject string:

Subject Watches
fs:~/Documents Everything under a folder, or one file by path or uid
fs:~/inbox/*.json:add Matching paths, one kind of change
kv:cart One key in your app's key-value store (kv:cart* for every key starting with cart)
kv:<handle>:* Keys another user shared with you
notif:app-user Your app's notifications for the user

Subjects and events has the full subject syntax and the shape of each event.

To run a handler on a timer instead of when data changes, create a schedule with onSchedule(). It runs your handler at an interval, on a cron expression in the user's time zone, or once at a set time, even while your app is closed. Pass the handler inline, and Puter stores it in the signed-in user's own user scope of your app:

await puter.events.onSchedule({
    cron: '0 9 * * 1-5',
    tz: 'America/New_York',
    handler: async ({ ctx, user }) => {
        await user.notifications.notify({ title: `Good morning, ${ctx.name}` });
    },
    context: { name: 'Ana' },
});

To ship one handler to every user, the app's owner can publish it under a name and each schedule or subscription binds to it with handlerName.

With the User-Pays Model, deliveries and schedule runs are billed to the user holding the subscription or schedule, not to you. See Rate Limits and Quotas for limits and costs.

Features

Watch a Folder
Watch a Key
Wait for a File
Run in the Background
List Subscriptions
Catch Up
<html>
<body>
    <script src="https://js.puter.com/v2/"></script>
    <script>
        (async () => {
            // (1) Create a directory to watch
            const dir = puter.randName();
            await puter.fs.mkdir(dir);

            // (2) Subscribe to everything under it
            const sub = await puter.events.onLocal(`fs:${dir}`, ({ event }) => {
                if (event.op === 'gap') {
                    puter.print(`missed some changes (${event.reason})<br>`);
                    return;
                }
                puter.print(`${event.op}: ${event.path}<br>`);
            });

            // (3) Change something, and the handler runs
            await puter.fs.write(`${dir}/hello.txt`, 'Hello!');

            // (4) Stop listening (cleanup)
            setTimeout(() => sub.off(), 2000);
        })();
    </script>
</body>
</html>

Watch a key-value key and a key prefix

<html>
<body>
    <script src="https://js.puter.com/v2/"></script>
    <script>
        (async () => {
            // (1) Exactly one key, and separately every key under a prefix.
            const one = await puter.events.onLocal('kv:cart', ({ event }) =>
                puter.print(`${event.op}: ${event.key}<br>`));
            const many = await puter.events.onLocal(
                `kv:${puter.appID}:cart:*`,
                ({ event }) => puter.print(`under cart: ${event.key}<br>`));

            // (2) `cart` reaches the first, `cart:items` only the second.
            await puter.kv.set('cart', { total: 0 });
            await puter.kv.set('cart:items', ['apple']);

            // (3) Cleanup
            setTimeout(async () => {
                await one.off();
                await many.off();
                await puter.kv.del('cart');
                await puter.kv.del('cart:items');
            }, 2000);
        })();
    </script>
</body>
</html>

React to a file that does not exist yet

<html>
<body>
    <script src="https://js.puter.com/v2/"></script>
    <script>
        (async () => {
            // (1) A directory to work in. `inbox/` below it does not exist yet.
            const dir = puter.randName();
            await puter.fs.mkdir(dir);

            // (2) Subscribe anyway. The subscription anchors on `dir` and
            //     matches the rest of the path as it appears.
            const sub = await puter.events.onLocal(
                `fs:${dir}/inbox/trigger.json:add`,
                ({ event }) => puter.print(`appeared: ${event.path}<br>`),
                { onError: (error) => puter.print(`subscription ended: ${error.code}<br>`) },
            );
            puter.print(`anchored on ${sub.anchor.path}, matching ${sub.match}<br>`);

            // (3) Create it, several directories deep
            await puter.fs.write(`${dir}/inbox/trigger.json`, '{}', {
                createMissingParents: true,
            });

            setTimeout(() => sub.off(), 2000);
        })();
    </script>
</body>
</html>

Keep watching with a handler after the page closes

The handler goes in with the subscription and runs in the events worker. It's stored in the signed-in user's own user scope of your app. The playground examples below run this form.

await puter.perms.request('backgroundEvents');
const sub = await puter.events.onPersistent({
    subject: 'fs:inbox',
    handler: async ({ event, ctx }) => {
        await fetch(ctx.endpoint, { method: 'POST', body: event.path });
    },
    context: { endpoint: 'https://example.com/ingest' },
});

// It outlives the page. End it, and remove its handler, when it's no longer needed.
await puter.events.unsubscribe(sub.subId);
await puter.events.handlers.remove(sub.handlerName, { scope: 'user' });

To share one handler between all your users, the app's owner publishes it once under a name and each subscription binds to it with handlerName. See puter.events.handlers.

List the persistent subscriptions this account holds

<html>
<body>
    <script src="https://js.puter.com/v2/"></script>
    <script>
        (async () => {
            const dir = puter.randName();
            await puter.fs.mkdir(dir);
            const sub = await puter.events.onPersistent({
                subject: `fs:${dir}`,
                context: { label: 'inbox' },
            });

            for (const row of await puter.events.list()) {
                puter.print(`${row.subject}: ${row.queue ? 'queue' : 'every listener'}`);
                puter.print(` (context: ${row.contextKeys?.join(', ') ?? 'none'})<br>`);
            }

            await puter.events.unsubscribe(sub.subId);
        })();
    </script>
</body>
</html>

Read the notifications missed while the app was closed

<html>
<body>
    <script src="https://js.puter.com/v2/"></script>
    <script>
        (async () => {
            let cursor;
            let seen = 0;
            do {
                const page = await puter.events.fetch({
                    subject: 'notif:app-user',
                    cursor,
                });
                for (const event of page.items) {
                    puter.print(`${event.type}: ${event.notification.title ?? ''}<br>`);
                    seen++;
                }
                cursor = page.cursor;
            } while (cursor);

            if (!seen) puter.print('nothing missed<br>');
        })();
    </script>
</body>
</html>

Functions

These Events features are supported out of the box when using Puter.js:

Examples

You can see various Puter.js Events features in action from the following examples:

Recipes