Watch a Folder for New Files


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

Your app can read and write files in the user's Puter account with puter.fs, the Puter file system API. A common feature built on it is an import folder. The user adds a file to a folder, and your app reads the file and saves what's in it.

This recipe builds a CSV importer. When the user adds a .csv file to the inbox folder, the app reads it and writes a summary file next to it. To know when a file is added, the app watches the folder with the Events API. It builds on Watch for Changes, which shows how to watch a folder to update what your app shows.

Your app processes the files while it's open, so it can use any library, such as a CSV parser. Files added while your app is closed are processed the next time it opens. To process them while your app is closed, see Run Code in the Background.

Watch the Folder

First, create the folder with puter.fs.mkdir(). Then subscribe to it with puter.events.onLocal(), which calls a function each time something in the folder changes. It takes a subject, which is a string that says what to watch, and the function to call. The subject fs:inbox/*.csv:add matches new .csv files in inbox:

async function summarize(path) {
    const blob = await puter.fs.read(path);
    const text = await blob.text();
    const name = path.split('/').pop().replace(/\.csv$/, '');

    await puter.fs.write(`inbox/${name}.summary.json`, JSON.stringify({
        rows: text.trim().split('\n').length - 1,
        at: Date.now(),
    }));
}

await puter.fs.mkdir('inbox', { createMissingParents: true });
await puter.events.onLocal('fs:inbox/*.csv:add', ({ event }) => summarize(event.path));

The function is called with { event }, and event.path is the full path of the new file. The summarize() function reads the file with puter.fs.read() and writes a summary with puter.fs.write(). In your app, this is where you'd parse the rows and save them. Add a CSV file to inbox, and its summary appears in the same folder.

Each app has its own folder in the user's account, and a relative path such as inbox is inside it. A relative path works the same way in a subject as in puter.fs. If the folder already exists, mkdir() returns it, so the code works every time the app opens.

In the subject, * matches any file name, and the :add suffix limits the subscription to new files. The summary ends in .json, so the subject doesn't match it, and writing it doesn't call the function again. All the patterns and suffixes are listed on Subjects and events.

On a website, the user has to be signed in. If nobody is, onLocal() opens the Puter sign-in window.

Process Files Moved Into the Folder

Uploads and copies arrive as add. A file moved in from another folder, for example by dragging it on the Puter desktop, arrives as a move, so the :add subject misses it. To process both, leave the suffix off and check event.op:

await puter.events.onLocal('fs:inbox/*.csv', ({ event }) => {
    const arrived = event.op === 'add' || (event.op === 'move' && !event.from);
    if (!arrived) return;
    return summarize(event.path);
});

A move has a from field when the file was already inside the watched folder, for example when it's renamed inside inbox. The function skips those, along with write and remove events. A file moved in from another folder has no from.

This depends on inbox existing when you subscribe. A subscription on a folder that doesn't exist yet watches from the nearest folder above it, so a file moved in from a folder next to inbox would have a from. That's why the code creates inbox first.

Catch Up on Missed Files

Files added while your app is closed send no events. Sometimes an event also can't be delivered, for example when the connection drops while a laptop sleeps. The function then gets a gap marker in its place. A gap marker is an event with op: 'gap' and no path, so it doesn't say which files were added. Watch for Changes has more on gaps.

To catch up, list the folder with puter.fs.readdir() and process every CSV file that has no summary yet. Run it once after subscribing, and again on each gap:

async function catchUp() {
    const items = await puter.fs.readdir('inbox');
    const done = new Set(items
        .filter(i => i.name.endsWith('.summary.json'))
        .map(i => i.name.replace(/\.summary\.json$/, '')));

    for (const item of items) {
        if (!item.name.endsWith('.csv')) continue;
        if (done.has(item.name.replace(/\.csv$/, ''))) continue;
        await summarize(item.path);
    }
}

function handler({ event }) {
    if (event.op === 'gap') return catchUp();
    const arrived = event.op === 'add' || (event.op === 'move' && !event.from);
    if (!arrived) return;
    return summarize(event.path);
}

const sub = await puter.events.onLocal('fs:inbox/*.csv', handler);
await catchUp();

The summary files record which CSV files are already processed, so the app doesn't need to store that anywhere else.

The code subscribes before the first catchUp(), so a file added during the listing still reaches the function. That file can be processed twice, so make processing safe to repeat. Here the second summary overwrites the first. In your app, save the rows under a key made from the file name, so processing the file again replaces its rows instead of adding a second copy.

Stop Watching

To stop, call off() on the subscription, for example when the user leaves the screen that shows the inbox:

// Later, when the user leaves this screen:
await sub.off();

Closing the tab ends the subscription on its own.

Full Code

async function summarize(path) {
    const blob = await puter.fs.read(path);
    const text = await blob.text();
    const name = path.split('/').pop().replace(/\.csv$/, '');

    await puter.fs.write(`inbox/${name}.summary.json`, JSON.stringify({
        rows: text.trim().split('\n').length - 1,
        at: Date.now(),
    }));
}

async function catchUp() {
    const items = await puter.fs.readdir('inbox');
    const done = new Set(items
        .filter(i => i.name.endsWith('.summary.json'))
        .map(i => i.name.replace(/\.summary\.json$/, '')));

    for (const item of items) {
        if (!item.name.endsWith('.csv')) continue;
        if (done.has(item.name.replace(/\.csv$/, ''))) continue;
        await summarize(item.path);
    }
}

function handler({ event }) {
    if (event.op === 'gap') return catchUp();
    const arrived = event.op === 'add' || (event.op === 'move' && !event.from);
    if (!arrived) return;
    return summarize(event.path);
}

await puter.fs.mkdir('inbox', { createMissingParents: true });
const sub = await puter.events.onLocal('fs:inbox/*.csv', handler);
await catchUp();

// Later, when the user leaves this screen:
await sub.off();
← All recipes