fix(events): resolve relative fs: paths like fs operations do (#4085)

* fix(events): resolve relative fs: paths like fs operations do

* docs(events): use bare relative paths in examples, test both relative forms

* docs(events): clarify that relative fs: subjects come back resolved

---------

Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
This commit is contained in:
Krish KumarandDaniel Salazar authored and GitHub committed 2026-10-08 15:23:53 -07:00
1 parent bb829885ef
commit 95b6f86fcc
17 files changed
+129 -27

No files matched your search

+4 -4
View File
@@ -48,7 +48,7 @@ With the [User-Pays Model](/user-pays-model/), deliveries are billed to the user
<script>
(async () => {
// (1) Create a directory to watch
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
// (2) Subscribe to everything under it
@@ -120,7 +120,7 @@ With the [User-Pays Model](/user-pays-model/), deliveries are billed to the user
<script>
(async () => {
// (1) A directory to work in. `inbox/` below it does not exist yet.
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
// (2) Subscribe anyway — the subscription anchors on `dir` and
@@ -174,7 +174,7 @@ With the [User-Pays Model](/user-pays-model/), deliveries are billed to the user
);
// (3) Subscribe. `context` is read now and never again.
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
const sub = await puter.events.onPersistent({
subject: `fs:${dir}`,
@@ -204,7 +204,7 @@ With the [User-Pays Model](/user-pays-model/), deliveries are billed to the user
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
const sub = await puter.events.onPersistent({
subject: `fs:${dir}`,
+1 -1
View File
@@ -53,7 +53,7 @@ The promise rejects with `{ message, code }`: `auth_canceled` if nobody was sign
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
const sub = await puter.events.onPersistent({
subject: `fs:${dir}`,
+1 -1
View File
@@ -33,7 +33,7 @@ A `Promise` that resolves when the subscription is gone. It never rejects: calli
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
const sub = await puter.events.onLocal(`fs:${dir}`, ({ event }) => {
+6 -5
View File
@@ -37,7 +37,7 @@ Called with `{ event }` for each delivery; see [The event](#the-event). `event.o
A `Promise` that resolves, once the server confirms the subscription, to:
- `subId` (String | null): The server's id for the subscription. It changes on every reconnect, so don't store anything against it.
- `subject` (String): The subject as you passed it. For `kv:`, `anchor.uid` is the app it resolved to; for `notif:`, the app, or your user id when acting as the account.
- `subject` (String): The subject as you passed it. A relative `fs:` path comes back resolved: `fs:inbox` returns as `fs:~/AppData/<appId>/inbox` in an app, `fs:~/inbox` otherwise. For `kv:`, `anchor.uid` is the app it resolved to; for `notif:`, the app, or your user id when acting as the account.
- `anchor` (Object): The node the subscription is attached to, as `{ uid, path }`: the subject itself, or its nearest existing parent if the subject doesn't exist yet. For `kv:`, `uid` is the app whose store is watched and `path` is the key prefix. Through a share handle, `uid` is the handle and `path` is empty. `path` is the one from when you subscribed; a later rename doesn't update it.
- `match` (String | null): The pattern matched under the anchor, if the subject had one. For a path that didn't exist yet, it's the rest of that path, and it covers that path and everything under it.
- `op` (String | null): The one operation this subscription is limited to, or `null` for all.
@@ -80,12 +80,13 @@ The same subjects work with [`onPersistent()`](/Events/onPersistent/).
### Files
- **Path**: absolute (`/alice/Documents`) or home-relative (`~/Documents`). A directory covers everything under it, at any depth.
- **Uid**: the `uid` of a file or directory, to follow one node wherever it moves.
- **Path**: absolute (`/alice/Documents`), home-relative (`~/Documents`), or relative (`inbox` or `./inbox`). A relative path resolves the same way [`puter.fs`](/FS/) paths do: under your app's `AppData` folder in an app, under home otherwise. A directory covers everything under it, at any depth.
- **Uid**: the `uid` of a file or directory, to follow one node wherever it moves. A relative name shaped like a uid is read as one; write `./<name>` to mean the path.
- **Op**: one of `add`, `write`, `move`, `remove`, `meta`. Leave it off to get all of them. Nothing emits `meta` yet.
```js
await puter.events.onLocal('fs:~/Documents', handler); // everything under Documents
await puter.events.onLocal('fs:inbox', handler); // inbox in your app's folder
await puter.events.onLocal('fs:~/Documents/notes.txt:write', handler); // one file, writes only
await puter.events.onLocal('fs:~/Pictures/*.png', handler); // wildcard within one segment
await puter.events.onLocal('fs:~/Projects/**/build.log', handler); // across directories
@@ -302,7 +303,7 @@ If the connection is down when this happens, or drops before `onError` is called
<script>
(async () => {
// (1) Create a directory to watch
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
// (2) Subscribe to everything under it
@@ -334,7 +335,7 @@ If the connection is down when this happens, or drops before `onError` is called
<script>
(async () => {
// (1) A directory to work in. `inbox/` below it does not exist yet.
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
// (2) Subscribe anyway — the subscription anchors on `dir` and
+3 -3
View File
@@ -133,7 +133,7 @@ Clients connect to the nearest region. Events reach a client wherever it's conne
A `Promise` that resolves to the subscription:
- `subId` (String): Its id, which [`puter.events.unsubscribe()`](/Events/unsubscribe/) takes. It never changes.
- `subject` (String): For `kv:` and `notif:`, the subject in full form: `kv:cart` comes back as `kv:<appId>:cart`. For `fs:`, as you passed it.
- `subject` (String): For `kv:` and `notif:`, the subject in full form: `kv:cart` comes back as `kv:<appId>:cart`. For `fs:`, as you passed it, except a relative path comes back resolved the same way [`onLocal()`](/Events/onLocal/) returns it.
- `anchor`, `match`, `op`: as `onLocal()` returns them.
- `delivery` (String), `targets` (Array), `handlerName` (String | null), `includeValue` (Boolean).
- `appUid` (String | null): The app that created it, or `null` if an account session did.
@@ -197,7 +197,7 @@ The promise rejects with `{ message, code }`:
);
// (3) Subscribe. `context` is read now and never again.
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
const sub = await puter.events.onPersistent({
subject: `fs:${dir}`,
@@ -229,7 +229,7 @@ The promise rejects with `{ message, code }`:
const handler = ({ event, ctx }) => console.log(ctx.label, event.path);
await puter.events.handlers.publish('onWrite', handler, { appUid: app.uid });
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
// Passing the function sends its hash: if somebody redeployed
+1 -1
View File
@@ -48,7 +48,7 @@ An app can only end subscriptions it created. An account session can end any of
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
const sub = await puter.events.onPersistent({ subject: `fs:${dir}` });
@@ -3,7 +3,7 @@
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
const sub = await puter.events.onPersistent({
subject: `fs:${dir}`,
@@ -3,7 +3,7 @@
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
const sub = await puter.events.onLocal(`fs:${dir}`, ({ event }) => {
@@ -9,7 +9,7 @@
const handler = ({ event, ctx }) => console.log(ctx.label, event.path);
await puter.events.handlers.publish('onWrite', handler, { appUid: app.uid });
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
// Passing the function sends its hash: if somebody redeployed
@@ -21,7 +21,7 @@
);
// (3) Subscribe. `context` is read now and never again.
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
const sub = await puter.events.onPersistent({
subject: `fs:${dir}`,
@@ -3,7 +3,7 @@
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
const sub = await puter.events.onPersistent({ subject: `fs:${dir}` });
@@ -4,7 +4,7 @@
<script>
(async () => {
// (1) Create a directory to watch
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
// (2) Subscribe to everything under it
@@ -4,7 +4,7 @@
<script>
(async () => {
// (1) A directory to work in. `inbox/` below it does not exist yet.
const dir = `~/${puter.randName()}`;
const dir = puter.randName();
await puter.fs.mkdir(dir);
// (2) Subscribe anyway — the subscription anchors on `dir` and
@@ -0,0 +1,56 @@
import getAbsolutePathForApp from '../../FileSystem/utils/getAbsolutePathForApp.js';
/**
* Resolves a relative `fs:` subject path the same way `puter.fs` operations
* do:
*
* - Already absolute (`/...`) or tilde-rooted (`~...`) -> unchanged.
* - UUID anchor (no `/` and no glob) -> unchanged.
* - Anything else -> run through `getAbsolutePathForApp`, which prepends
* `~/AppData/<appID>/` when there is an app context, or `~/` otherwise.
*
* The operation filter (`:add`, `:write`, ...) and glob suffix are stripped
* before the path is tested and re-attached after, so `fs:./inbox:add`
* resolves the `./inbox` part only.
*
* @param {string} subject Raw subject string, e.g. `fs:./inbox:add`.
* @param {import('../../../index.js').Puter} [puter] The Puter instance,
* defaulting to `globalThis.puter`. Needed for `appID`.
* @returns {string} Subject with the `fs:` path portion resolved.
*/
export const resolveSubject = (subject, puter = globalThis.puter) => {
// Only `fs:` subjects embed a path; everything else passes through.
if (!subject.startsWith('fs:')) return subject;
// Strip the `fs:` family prefix.
const rest = subject.slice('fs:'.length);
// Split off an optional trailing `:op` filter (`:add`, `:write`, ...).
// FS ops are single-word suffixes with no slashes. Split on the last `:`
// only when what follows it looks like a known op (no `/`).
let pathPart = rest;
let opSuffix = '';
const lastColon = rest.lastIndexOf(':');
if (lastColon !== -1) {
const candidate = rest.slice(lastColon + 1);
const FS_OPS = ['add', 'write', 'move', 'remove', 'meta'];
if (FS_OPS.includes(candidate)) {
pathPart = rest.slice(0, lastColon);
opSuffix = `:${candidate}`;
}
}
// Already absolute or tilde-rooted -> nothing to do.
if (pathPart.startsWith('/') || pathPart.startsWith('~')) {
return subject;
}
// Looks like a bare UUID (no slashes, no glob chars) -> treat as uid, not path.
const reLooksLikeUUID = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
if (reLooksLikeUUID.test(pathPart)) return subject;
// Relative path - resolve it the same way FS operations do.
const resolved = getAbsolutePathForApp(pathPart, puter);
return `fs:${resolved}${opSuffix}`;
};
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { resolveSubject } from './resolveSubject.js';
const app = { appID: 'app-123' };
const uid = '0f1e2d3c-4b5a-4968-8776-655443322110';
describe('resolveSubject', () => {
it('resolves bare and ./ relative paths alike under the app folder', () => {
for ( const ref of ['inbox', './inbox'] ) {
expect(resolveSubject(`fs:${ref}`, app)).toBe('fs:~/AppData/app-123/inbox');
expect(resolveSubject(`fs:${ref}/a.txt:write`, app))
.toBe('fs:~/AppData/app-123/inbox/a.txt:write');
expect(resolveSubject(`fs:${ref}/**/*.json:add`, app))
.toBe('fs:~/AppData/app-123/inbox/**/*.json:add');
}
});
it('resolves relative paths under home without an app', () => {
expect(resolveSubject('fs:inbox', {})).toBe('fs:~/inbox');
expect(resolveSubject('fs:./inbox:add', {})).toBe('fs:~/inbox:add');
});
it('leaves absolute, home, and uid subjects alone', () => {
for ( const subject of [
'fs:~/Documents',
'fs:/alice/Documents:write',
`fs:${uid}`,
`fs:${uid}:remove`,
] ) {
expect(resolveSubject(subject, app)).toBe(subject);
}
});
it('reads ./<uid-shaped name> as a path', () => {
expect(resolveSubject(`fs:./${uid}`, app)).toBe(`fs:~/AppData/app-123/${uid}`);
});
it('leaves other families alone', () => {
expect(resolveSubject('kv:cart', app)).toBe('kv:cart');
expect(resolveSubject('notif:account', app)).toBe('notif:account');
});
});
+5 -3
View File
@@ -1,5 +1,6 @@
import { assertHandler, assertSubject } from './lib/validate.js';
import { needsSignIn, signInVisitor } from './lib/signIn.js';
import { resolveSubject } from './lib/resolveSubject.js';
/** @typedef {import('./lib/subscription.js').EventSubscription} EventSubscription */
/** @typedef {import('./types.js').EventHandler} EventHandler */
@@ -24,8 +25,9 @@ import { needsSignIn, signInVisitor } from './lib/signIn.js';
* `auth_canceled` if the visitor closes it.
*
* @this {import('./index.js').EventsModule}
* @param {string} subject The subject to watch, e.g. `fs:~/Documents` or
* `fs:~/Documents/inbox.txt:write`.
* @param {string} subject The subject to watch, e.g. `fs:~/Documents`,
* `fs:~/Documents/inbox.txt:write`, or `fs:./inbox` (relative paths resolve
* against the app's root directory, the same way `puter.fs` operations do).
* @param {EventHandler} handler Called with `{ event }` per delivery.
* @param {OnLocalOptions} [options]
* @returns {Promise<EventSubscription>} Resolves once the server has confirmed
@@ -36,5 +38,5 @@ export async function onLocal (subject, handler, options = {}) {
assertHandler(handler);
if ( needsSignIn(this.puter) ) await signInVisitor(this.puter);
return await this.channel.subscribe(subject, handler, options);
return await this.channel.subscribe(resolveSubject(subject, this.puter), handler, options);
}
@@ -2,6 +2,7 @@ import { PuterJSError } from '../../lib/PuterJSError.js';
import { request } from './lib/api.js';
import { prepareHandler, serializeContext } from './lib/handlerSource.js';
import { assertSubject } from './lib/validate.js';
import { resolveSubject } from './lib/resolveSubject.js';
/** @typedef {import('./types.js').OnPersistentOptions} OnPersistentOptions */
/** @typedef {import('./types.js').PersistentSubscription} PersistentSubscription */
@@ -35,7 +36,7 @@ import { assertSubject } from './lib/validate.js';
export async function onPersistent (options = {}) {
const { puter } = this;
assertSubject(options?.subject);
const subject = resolveSubject(options.subject, puter);
const { handler, handlerName } = options;
// An inline handler is source the server has to match against something it
// already has, and a name is the only thing it can match against.
@@ -60,7 +61,7 @@ export async function onPersistent (options = {}) {
: await prepareHandler(puter, handler);
const body = {
subject: options.subject,
subject,
...(options.delivery ? { delivery: options.delivery } : {}),
...(options.targets ? { targets: options.targets } : {}),
...(handlerName ? { handlerName } : {}),