* fix: harden events dispatch, single delivery and KV share handles Dispatch: a filtered subscription used the anchor path stored at subscribe time, so renaming or moving the anchor folder silently ended its deliveries; dispatch now resolves the anchor's live path from the event's own ancestor chain. A move out of a watched folder now reaches that folder's subscribers, with `from` only for rows that watched the source side. Gap markers are authorized like deliveries and coalesced per subscription and subject instead of fanning per lost event. Session subscriptions: the per-socket cap decides on the write, not before it; an orphaned watched-set token heals on refresh; durable rows keep their watch window when a session subscribe touches the same keys. `self` is false when the acting user is unknown. Single delivery: a subscription in backoff or suspended with a backlog pinned the sweeper's head and starved everyone behind it — the sweep now defers it. Only a settled handler run bills a delivery. A socket-only account row no longer wedges after two attempts nobody received. The lease is twice the handler timeout; remote candidates have their own attempt counter; the region depth reconcile runs once a minute region-wide with a bounded scan. KV share handles: a grantee no longer sees the owner's namespace and absolute prefix on the subscribe answer or listing, nor in the delivery token; revoking a wider handle retires the handles it covers; minting the same handle twice returns the existing one, after the delegation check; a row whose event cannot be re-based onto its handle is dropped rather than delivered raw. * fix: presence survives replication, long sessions and region churn One presence item per (user, app) with per-region map fields lost a region whenever two regions joined inside the replication window, and nothing ever put it back. Presence is now one item per (user, app, region): each region writes only its own, a leave or repair retires it conditionally on its own write stamp, and a read is a prefix query. Items carry a 48 h ttl refreshed by a claim-gated write off the existing socket renew path, at most once per 12 h, so a tab that stays connected keeps its region in the row. A region that answered "no socket" or completed a leave releases a shared pin, so a reconnect on another node rejoins and a flapping client cannot force a replicated write per cycle. Cached rows expire after a minute; unaddressable region names are filtered and pruned; relayed acks settle under a bounded concurrency; the forward queue is bounded in bytes as well as items. * feat: indexes for the event_subscriptions hot queries Handler publish, remove and listing, and the hourly expiry and suspension sweeps, all scanned `event_subscriptions`. Adds (app_uid, handler_name), (expires_at) and (suspended_at, id), guarded on every engine. Existing migrations: the postgres widens are now guarded so a boot does not take an exclusive lock for a no-op, the kv_share_handles grantee FK gets an index, the sqlite notification rebuild is transactional and idempotent. * fix: notification writes go through the registry The driver's `create` bypassed the type registry, producing uncatalogued rows with no size bound; it now requires a registered type, caps the payload, and answers 400 rather than 500 for a bad one. `mark_acknowledged` emits the ack other tabs listen for, and only when a row was actually changed. * fix: the handler scanner, unsubscribe, and the in-tab handler environment The free-variable scanner skipped arrows inside a declaration's initializer, so `const ids = event.items.map(x => x.id)` was refused, and treated a name after a comma in a nested initializer as bound, so a real free variable slipped through to fail on first delivery. `unsubscribe()` now drops the durable routing entry so the events socket can close. A broadcast handler running in the tab gets `user` and `fetch` like the worker gives it. `single` without a handler name is refused before the round trip. * docs: events limits, error codes and the background-workers section Retention is deployment-configured rather than a fixed 14 days, and the template no longer ships it armed. Documents `events_terminal`, the two per-event gap reasons, the subject length and listing caps, the `from` field on moves, and the handle-relative anchor. The sessions manager hides the background-workers section when the server has none to show. * feat: a background handler acts as the app does for its user A handler's `user` was a five-minute access token scoped to the subscription's `list` grant, which could stat the changed file but not read it, and could not reach the app's KV or AppData — so an app told that a file was written could do nothing with it. It now runs with the same authority the app has for that user in a tab: an app-under-user worker session, one row per (user, app) named `events:handlers`, visible and revocable in the sessions list. The `events:background` consent is what authorizes running it unattended, and is re-checked before every mint. The wider token exposed two things: puter.js opens a filesystem socket the moment it has a token, which would have parked the isolate in the app's own delivery room and steered deliveries at it; the events client now opts out of sockets (and the per-open bookkeeping) before construction, and is memoized per token in the isolate. And four filesystem operations assumed a socket exists; they no longer do.
puter.js API test environment
Client-agnostic test suites for puter.js, run against a self-contained
in-memory Puter server — no external server, no stdout password scraping,
no .env. The same suite files execute on three platforms through thin
adapters:
| Runner | Platform | How the SDK runs |
|---|---|---|
runners/node.test.ts |
node.js | Built SDK bundle loaded into a fresh vm context per test (like src/init.cjs) |
runners/browser.test.ts |
headless Chromium (playwright) | Fixture page served same-origin on the API host loads /puter.js/v2 from the server itself |
runners/workerd.test.ts |
local workerd (Miniflare) | Suite bundle deployed as a real Puter worker via puter.workers.create, dispatched through the local worker proxy |
Running
Build the SDK bundle and worker preamble once (repeat after SDK changes):
npm run build:workerLib
Then, from the package root:
npm run test:puterjs # all three platforms
npm run test:puterjs:node
npm run test:puterjs:browser # needs `npx playwright install chromium` once
npm run test:puterjs:workerd
How it works
Each runner boots setupPuterTestEnv() (from src/backend/testUtil.ts) in
beforeAll: a fully in-memory backend (sqlite / dynalite / redis-mock /
fauxqs S3) listening on a real ephemeral port, with the production
extensions loaded and two deterministic users seeded:
admin— member of the admin group,testuser— a regular, non-privileged user (what suites run as).
The env manifest ({ origin, apiOrigin, users } with fixed passwords and
pre-minted session tokens) is JSON-serializable and crosses into whatever
runtime executes the tests. Root-only routes (e.g. POST /login) live on
origin; the SDK talks to apiOrigin (the api. subdomain host).
Adding tests
Tests are added once and run on all three platforms — never write a per-platform test here.
- Existing area (apps, auth, fs, kv): add a test to the matching
suites/<name>.suite.ts— one entry in the object, key is the test name, value gets the contextt. - New area (e.g. hosting): create
suites/hosting.suite.tsand register it insuites/index.ts(explicit list, no globbing — esbuild bundles exactly this list for the browser/workerd runners).
import { suite } from '../harness/types.ts';
export default suite('example', {
'does the thing': async (t) => {
await t.puter.fs.write(`/${t.env.users.user.username}/x.txt`, 'hi');
t.assert.ok(await t.puter.fs.stat(/* … */));
},
});
Rules that keep a suite runnable everywhere:
- Platform-agnostic only. No node/browser/workerd-specific imports —
a suite may use the SDK instance (
t.puter, authed as the regular user), globalfetch, andt.assert(ok/equal/deepEqual/rejects). - Admin or cross-user assertions go through plain
fetchwitht.env.users.admin.token(seeauth.suite.ts) — that works identically on every platform. - Unique resource names per test (file paths, kv keys): tests in a suite share one server and one user, so don't reuse names across tests.
- The runners in
runners/enumerate suites automatically — adding a suite requires no runner changes.
Iterate fast with npm run test:puterjs:node (boots in ~3s); run
npm run test:puterjs before pushing to cover browser and workerd too.