mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-12 06:41:55 +00:00
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:
1 parent
bb829885ef
commit
95b6f86fcc
17 files changed
+129
-27
No files matched your search
@@ -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}`,
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in new issue
Block a user