mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-04 21:15:50 +00:00
4851df716ca2ee58b240e4ef92eb2cb37b4bccbe
7311
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4851df716c |
fix(kv): KV share lifecycle hardening (#4429)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
* fix(kv): harden the KV share lifecycle - Retire a deleted app's live shares before an origin app is registered again under the same uid. The retire runs before the app row exists and throws on failure, so a failed launch leaves no row and the next one retries. - Record deleted-app retires and uninstall cleanups (retire, then purge) as reserved items before they run. A lease keeps one node on a job; a failed run raises an alarm and backs off, and a run that hits its time budget or is cut short by a restart resumes on the next check. Each job is bounded to handles made before it was recorded. - Uninstalling with deleteData records the job and returns; the retire and purge run off the request. The purge checks whether the app was authorized again before deleting each page, so data written after that is kept. - Take a per-region lock when minting and re-check for a live handle under it, so concurrent shares of one region make one handle. - Retire the handles an account holds before the account is deleted, so the owners' subscriptions through them end instead of staying active. * fix(events): bound share-handle session rows by the per-connection cap The count across a holder's connections also counted connections that had dropped but weren't reaped yet (a crashed node, a deploy, a network blip the server hasn't noticed), so resubscribes after a reconnect could be refused with `events_subscription_limit`. Rows made through a share handle now count toward the 50-per-connection cap like any other, and with the open-connections cap per account that bounds what one holder can put in the owner's keyspace. The handle index stays for ending rows on retire. Rows are added to it with the row itself and taken out when the row is dropped, and members whose row is gone are cleared when a retire reads it. * fix(events): keep a retire's durable settle running if ending sessions fails Ending a retired handle's session subscriptions reads Redis. A failure there is now logged and the durable suspension that follows still runs, so those rows' backlogs are still purged. * perf(events): ask once per handle when a refresh screens rows, and share handle reads A socket refresh asks whether a share handle still stands once per handle, holder and app, rather than once per row: rows through one handle share its grant. Concurrent cache misses on one handle share a single read; a forget drops the shared read so later callers start a fresh one. * fix(permissions): chunk the exact-match subtree delete The holder-scoped subtree delete names every matched grant, so a large subtree could exceed an engine's limit on bound parameters. It now deletes 500 grants per statement. * fix(kv): give each holder a share of a namespace's handle budget One holder could spend a namespace's whole handle budget and starve the others. Each holder now gets at most 3,750 of the namespace's 15,000 units per 10 seconds, and the owner's own calls through a handle spend none. The holder's budget is checked before the grant lookups, so throttled retries cost none; the namespace's is only told to callers the grant admits. Multi-unit charges use a new counted claim that keeps two counters per key instead of a sorted-set member per unit. * fix(kv): refuse handles older than their app and claim cleanup jobs globally - A share handle created before its app row reads as gone: in the KV driver, in the handle cache, on the events subscribe path, and in the delegate implicator. An origin app registered again under a deleted app's uid can't reach its predecessor's shares, whatever created the row. The app-token launch no longer retires inline; it records a background retire and never waits on it or fails because of it. The clock-skew rule is shared with app sessions. - Cleanup job records now carry their lease, attempts and retry time, and a node claims a job by compare-and-set on its record. A job stays with the deployment that recorded or last ran it, and another one adopts it only after it has gone unclaimed well past due. - The job check pages through records from where the last look stopped, so waiting records don't hide due ones. After 10 failed runs a job stops retrying with a warning alarm, and its record expires after 30 days. - A mint that waits out the region lock looks for the share again and returns it, or goes ahead, instead of failing with too_many_requests. - The record-failure alarm dedups; an unfinished run is a log line. * fix(kv): hide shares older than their app from re-shares and listings After an app is registered again under a deleted app's uid, its predecessor's live handles stayed visible: sharing the same region again handed back the old, dead handle, and both sides' listings showed the old handles and labels. findLive and both listing queries now leave out a live handle created before its app row, the same rule handle resolution uses, so a re-share makes a new handle and the listings, totals included, skip the old ones until their retire lands. A new share also retires, grants and all, the handles its owner gave the same holder for the earlier app first, so withdrawing those later can't take the new grant with it. * fix(events): keep a loose cap on subscriptions through one share handle Without a count across connections, a holder could put up to 20,000 rows in the owner's keyspace (50 per connection, 400 connections), each one evaluated on every owner write. The cap is back at 1,000 per holder per handle, far above what connections that dropped without being reaped leave behind. The index is only read row by row once it's past the cap. * fix(kv): harden the share cleanup jobs - Job records are keyed by domain, so a deployment that shares the table but not the database or broadcast (staging beside production) never runs another's jobs. - A job whose record can't be written runs unrecorded instead of not at all, and a failed check for live handles records the job anyway so it retries. Failures that used to be swallowed are logged. - Recording a job again keeps its attempts and backoff; one that gave up gets one more attempt. - A system retire gives back the grant of a live share it doesn't cover inside a withdrawn region, and a share made under a reused uid grants again once its row stands, so a concurrent retire can't leave a live handle without its grant. * fix(kv): don't cache a live handle a retire overtook during its app lookup The live read decided whether it could cache before awaiting the app lookup, so a retire's forget that landed during that lookup was overwritten with the handle as live for up to 60 s. It's now checked after the lookup. * chore(kv): word the job key comment for any install |
||
|
|
faa8cd99dd |
fix(kv): per-holder share of the KV handle budget (#4433)
* fix(permissions): chunk the exact-match subtree delete The holder-scoped subtree delete names every matched grant, so a large subtree could exceed an engine's limit on bound parameters. It now deletes 500 grants per statement. * fix(kv): give each holder a share of a namespace's handle budget One holder could spend a namespace's whole handle budget and starve the others. Each holder now gets at most 3,750 of the namespace's 15,000 units per 10 seconds, and the owner's own calls through a handle spend none. The holder's budget is checked before the grant lookups, so throttled retries cost none; the namespace's is only told to callers the grant admits. Multi-unit charges use a new counted claim that keeps two counters per key instead of a sorted-set member per unit. |
||
|
|
279bb8beb6 |
fix(events): KV share handle subscription follow-ups (#4434)
* fix(events): bound share-handle session rows by the per-connection cap The count across a holder's connections also counted connections that had dropped but weren't reaped yet (a crashed node, a deploy, a network blip the server hasn't noticed), so resubscribes after a reconnect could be refused with `events_subscription_limit`. Rows made through a share handle now count toward the 50-per-connection cap like any other, and with the open-connections cap per account that bounds what one holder can put in the owner's keyspace. The handle index stays for ending rows on retire. Rows are added to it with the row itself and taken out when the row is dropped, and members whose row is gone are cleared when a retire reads it. * fix(events): keep a retire's durable settle running if ending sessions fails Ending a retired handle's session subscriptions reads Redis. A failure there is now logged and the durable suspension that follows still runs, so those rows' backlogs are still purged. * perf(events): ask once per handle when a refresh screens rows, and share handle reads A socket refresh asks whether a share handle still stands once per handle, holder and app, rather than once per row: rows through one handle share its grant. Concurrent cache misses on one handle share a single read; a forget drops the shared read so later callers start a fresh one. * fix(events): keep a loose cap on subscriptions through one share handle Without a count across connections, a holder could put up to 20,000 rows in the owner's keyspace (50 per connection, 400 connections), each one evaluated on every owner write. The cap is back at 1,000 per holder per handle, far above what connections that dropped without being reaped leave behind. The index is only read row by row once it's past the cap. |
||
|
|
52aedde3c5 |
fix(kv): KV share authz and load hardening (#4430)
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
* fix(permissions): delete user-to-user grants by their exact spelling MySQL compares the permission column case-insensitively, and LIKE folds case on MySQL and SQLite, so withdrawing a grant could also remove one spelled differently, which checks and the flat view treat as distinct. The single, batch and subtree deletes for one holder now match the permission byte for byte. * fix(auth): refuse key-value share grants on /auth/revoke-user-user Withdrawing a share's grant there left its handle live and listed. The route now refuses kv-share permissions and points to puter.kv.unshare(). * perf(kv): ask whether a delegate app is authorized once per check The app-delegate implicator re-read the app's authorization for every option a refused check offered, one read per ancestor of a deep key. * fix(puter-js): default a kvShare consent request to read puter.kv.share() creates read shares by default, so a consent asked for with the defaults (watch) didn't cover the share made with the defaults. * fix(kv): treat a private entry as missing for a share holder's writes Through a handle, a private entry reads as absent, but writes to it were refused with an error naming it as private, and onlyIfAbsent answered false. Deleting one through a handle now answers as deleting a missing key does and leaves the entry in place. Any other write is refused with kv_handle_access_denied, the same error as a call the share's level doesn't allow, onlyIfAbsent included. Cross-app access is unchanged. * fix(kv): cap handle traffic per owner namespace Every call through a share handle lands on its owner's namespace, and per-holder limits put no bound on what all holders together could send there. Handle calls into one namespace now share a budget of 6,000 units per 10 seconds, counted from the storage reads and writes each call consumes (at least one), and are refused with too_many_requests past it. |
||
|
|
7f832ede43 |
fix(events): KV share handle subscription hardening (#4428)
* fix(events): keep a share's prefix out of handle subscribe errors and limits A `kv:<handle>:<key>` pattern is now checked against the 256-character limit as the holder wrote it, before it's composed onto the shared prefix. The composed pattern is compiled with room for the longest prefix a grant can carry, at subscribe and at dispatch, so neither the limit nor its error depends on the prefix. A handle subscription's `match` is read off the holder's own subject instead of the stored composed pattern, so whether it's `null` no longer depends on how deep or long the prefix is. * fix(events): cap and end session subscriptions made through a share handle Session subscriptions made through a share handle are indexed under the handle in the owner's keyspace. A holder may keep 100 of them through one handle across all their connections; past that, subscribe fails with `events_subscription_limit`. The owner's own subscriptions through the handle don't count. Retiring a handle now ends its session subscriptions in the same region and sends `events.ended` with `permission_revoked`, alongside the existing durable settle. A socket refresh no longer renews a row whose handle was retired or whose grant is gone: it ends it the same way, which is how a retire made elsewhere reaches these rows. * fix(kv): read a forgotten share handle from the primary After a handle or its owner is forgotten, reads of that handle go to the primary for the next minute, so a replica that hasn't caught up can't put the old row back in the cache. A read that a forget lands on while it's in flight is answered but not cached. * fix(events): read a socket ref's subId after the last `|` A KV token carries the subscribed key, which can contain `|`, so splitting the ref at the second one cut the token short. A subscription on such a key couldn't be unsubscribed, and a reap left its row behind in the owner's keyspace. SubIds never contain `|`, so the ref is split at the last one. |
||
|
|
58275ccd52 |
ci(puterjs): check out the coverage base leg from this repo (#4431)
The base leg checked out the base branch from the PR's head repo. For a fork PR that is the fork's copy of main, which can predate the current Node version and fail npm ci. |
||
|
|
11114c80d6 |
fix(auth): fail closed on otp-login token reuse check (#4421)
/login/otp and /login/recovery-code marked the otp-login token used through a rate-limit check that admits on a store error, so while the store was unavailable a captured token could complete another sign-in. The check now fails closed. Also drops the check-translations scripts and the AGENTS.md step that runs them; the script they call was removed in #2873. |
||
|
|
5d907d18c9 |
fix(socket): bound acks a receipt ask leaves on sockets that never answer (#4422)
An acked broadcast leaves its callback in socket.io's per-socket ack map on every socket it reaches, and nodes other than the asker free it only when that socket answers or disconnects. Each socket's map is now a BoundedTtlMap capped at 64 entries and 60 s, so a socket that never answers holds a bounded number and a late answer is ignored. BoundedTtlMap drops expired entries on set so its TTL frees memory too. |
||
|
|
2d5e037599 |
fix(ai): bill realtime responses the upstream drops before reporting usage (#4423)
When the upstream realtime connection closes, for any reason, with a started response that never sent response.done, the relay now records that response at its credit hold (8k audio in + 4,096 audio out at the session model's rates, through the AI cost factor) instead of releasing the hold uncharged. The hold is released once the charge is recorded, and a warning alarm is still raised. Client conversation.item.retrieve events and MCP tools in session.update or response.create are refused with an invalid_event error and a 1008 close. |
||
|
|
8d3a1ccc2b |
feat(notifications): remove a notification from the dashboard history (#4412)
Each notification in the dashboard list gets a remove (✕) control next to mark-as-read. It calls the new POST /notif/delete, which deletes the user's own row and sends `notif.removed` to their other tabs so the entry and any toast for it disappear everywhere. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Daniel Salazar <daniel.salazar@puter.com> |
||
|
|
a1fe14cbd5 |
fix(ai): bound large AI request bodies per user and to chat (#4420)
Follow-up to #4408. The 503 returned when a process holds too many large bodies is load shedding, so it records an info alarm instead of a critical one. One user can hold at most 128 MiB of large bodies at once; past that the request gets 429 with Retry-After, so a single account can't take a process's whole budget, e.g. with slow uploads. Through /drivers/call only puter-chat-completion takes a body over 50 MiB; other drivers keep the default limit and get 413. The check reads the parsed body's interface, since route matching ignores case and trailing slashes. |
||
|
|
a195605003 |
fix(ai): accept 128 MiB Anthropic message requests (#4408)
* fix(ai): accept larger Anthropic message requests * fix(ai): accept 128 MiB bodies on /drivers/call and OpenAI routes Extend the 128 MiB JSON limit to /drivers/call (puter.ai.*) and the OpenAI-compatible routes, alongside the Anthropic ones. A body over the default 50 MiB is read only after a header or cookie credential resolves; otherwise the global parser rejects it with 413, so unauthenticated callers can't make the server buffer and parse 128 MiB. Large bodies skip the rawBody copy, and a per-process budget caps how many bytes of them are held at once, returning 503 with Retry-After past it. The prompt token estimate no longer splits text on whitespace, which took seconds and allocated a string per word on large prompts. It counts whitespace runs in a loop, scans at most 1 MiB per prompt and extrapolates the measured density past that. --------- Co-authored-by: Daniel Salazar <daniel.salazar@puter.com> |
||
|
|
1e39936d12 | fix: custom domains (#4419) | ||
|
|
1390a87280 |
fix(docker): hoist sharp so the backend can load it from dist (#4400)
The backend runs from dist/src/backend, so Node resolves modules from the root node_modules, never src/backend/node_modules. Since sharp ^0.35.5 (#4081) only the backend asked for that version, so npm nested it under src/backend while the root copy stayed miniflare's dev-only 0.35.4, which the image doesn't ship. The 26.10.1 image then crash-loops on start with "Cannot find module 'sharp'" from the Replicate image provider. Declaring sharp ^0.35.5 at the root hoists the backend's version, the same way other backend runtime deps are reachable from dist. Fixes #4394 Co-authored-by: Daniel Salazar <daniel.salazar@puter.com> |
||
|
|
6d47db9358 |
chore(deps): bump nodemailer from 9.1.1 to 10.1.4 (#4386)
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 9.1.1 to 10.1.4. - [Release notes](https://github.com/nodemailer/nodemailer/releases) - [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodemailer/nodemailer/compare/v9.1.1...v10.1.4) --- updated-dependencies: - dependency-name: nodemailer dependency-version: 10.1.4 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
2e57214bb3 | fix: update outdated HOME_URL in /etc/os-release (closes #4329) (#4392) | ||
|
|
7ac2f2e247 |
feat(ai-chat): let requests opt out of provider fallback (#4343)
* ai-chat: fallback: false fails on the first route instead of rerouting A chat request whose first provider fails is retried on up to two other providers that serve the same model, and the caller pays for whichever one answers. Callers who want one specific route, or who want to handle a vendor failure themselves, had no way to turn that off. Passing fallback: false (puter.js option, /drivers/call arg, and the OpenAI-compat and Anthropic-compat request bodies) makes the first attempt's failure final: the route is still marked unhealthy, and the error is classified the same way an exhausted chain would be. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ai-chat: cover fallback: false in the SDK suite Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ai-chat: fallback: false rejects a pinned provider with no route for the model Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * docs: plainer wording for the fallback option Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> |
||
|
|
0982e696a5 |
docs(install): explain how to install Docker instead of stopping at a dead end (#4411)
* docs(install): explain how to install Docker instead of a dead end The installer stopped with 'missing required command: docker' and nothing else, which left self-hosters on a clean machine with no next step. Since Puter self-hosting is a Docker Compose stack, that check is also the very first thing a new user hits. Print the official install links for macOS/Windows and Linux plus the 'docker compose version' sanity check, and cover the same ground in doc/self-hosting.md under Requirements and Troubleshooting. The installer still does not install Docker itself — per the maintainer's note on #4346, pulling a remote script into a system package manager is too intrusive. This only makes the failure actionable. * fix: remove extra sh stuff already merged --------- Co-authored-by: Daniel Salazar <dash.salazar@gmail.com> |
||
|
|
fea89c91b8 |
feat(puter-js)!: remove notifications.registerDevice (#4409)
* docs(notifications): registerDevice takes ios or android * feat(puter-js)!: remove notifications.registerDevice |
||
|
|
489a7c0129 | fix(install): improve Docker missing error with platform-specific instructions (#4384) | ||
|
|
e5ba469ffc |
fix(notifications): keep app messages in their app, off the desktop (#4410)
* fix(notifications): keep app messages in their app, off the desktop An app's notifications now reach only that app: its live feed on notif:app-user and, through the app's own sockets, receipts and pushes. The desktop's listing, unread replay, live wire and notif:app-user subscription leave them out, the same way key-value share notices already are, which covers rows stored before this change too. Puter's own notifications and shares still show on the desktop. The desktop and its notifications panel also ignore an app message they are handed, and the desktop no longer notes one as received. The notification docs now say where an app's notifications show. * docs(notifications): say an app's notifications reach the app, not the panel * test(notifications): wait for the unread replay instead of sleeping past it * fix(gui): show notifications a frozen tab missed once it reconnects A tab that was frozen or asleep keeps its socket registered until it resumes, so a notification created meanwhile counts as shown and no unread replay carries it: the tab never showed it, not even after a reload. On a socket reconnect the desktop and the dashboard now read the undismissed notifications created since the page loaded and raise the ones that page has not shown or seen dismissed, once. Every unread replay is taken too, not only the first, and no longer only while the events feed is down; a per-page log keeps either from raising anything twice. App messages stay out. * fix(gui): catch up after a reconnect without the page's clock The reconnect catch-up compared each row's server time with the page's own load time, so a page whose clock ran behind raised, on its first reconnect, every undismissed notification from before it loaded. A page now notes the undismissed notifications it knew at load (the desktop from one listing, the dashboard from its first) and a reconnect raises only those it neither knew then nor has raised since. The dashboard reads them from the listing its reconnect refresh already takes. The per-page log is bounded, and a caught-up notification shows its title as the body when it has no text, as the replay does. * style(gui): keep the reconnect comments to one line |
||
|
|
2a3c181657 |
DNS ui changes (#4417)
* DNS ui changes |
||
|
|
4881264c88 |
feat(ai): realtime reconnect notice, context restore and retry hardening (#4415)
* test(ai): end every realtime relay test's upstream before the next The fake upstream now answers response.create with response.created first, as OpenAI does, so closing those sessions no longer waits out the settle window. * feat(ai): ask realtime clients to reconnect before a restart Open realtime sessions get a puter.reconnect event when the drain starts, with close_in_ms set to the drain window, and new sessions are still refused until the 1012 close at shutdown. A host extension can announce a restart early by emitting serverRestartAnnounced; that only sends the notice, so a false alarm leaves the server working normally. A session gets the notice at most once a minute. onServerDrainStart now receives the drain duration. * fix(ai): retry realtime reconnects for about a minute and wait out offline browsers Five attempts gave up after about 15 seconds, shorter than a server restart. The conversation makes ten attempts with the backoff capped at 10 seconds, and while the browser reports being offline it waits for the online event, up to two minutes per reconnect, instead of spending attempts. close() ends either wait and removes its listener. * feat(ai): restore recent conversation context after a realtime reconnect The provider starts a fresh session on every reconnect, so the conversation lost all earlier items. The SDK now records finished items it can recreate from the provider's own events: user text, user speech with a transcript, assistant replies as text, and function calls with their outputs. After the new session acknowledges its settings, and before ready fires, it adds back the newest of them, up to 40 items and 48 KiB, which stays under the relay's upload burst. Assistant replies are cut to what the user saw or heard: the deltas shown before an interruption, and the share of the transcript matching the audio played before a truncation or a drop. Deleted items, unfinished replies, images, speech without a transcript and incomplete function calls are left out, as are function outputs whose call falls outside the window. Restored copies take over their new item IDs. A refused item is reported once as context_restore_failed without failing the reconnect. * feat(ai): move a realtime conversation to a new session on the restart notice On puter.reconnect the conversation waits until it is idle: no reply requested, generating or playing, no user turn being spoken or transcribed, no function call awaiting its output and no acknowledgment pending. Then it reconnects through the usual path, emitting reconnecting (code 1012) and ready with reconnected: true, without an error. Until then the session keeps working, and if the server closes it first the normal 1012 reconnect takes over. close() at any point stops the wait. * docs(ai): realtime restart notice, context restore and reconnect limits * test(ai): realtime reconnects against the local server The mock provider answers with item events and whole replies like OpenAI. New cases cover a dropped connection (cut through a TCP proxy so the client sees 1006), a host-announced restart, and a draining server refusing the reconnect; the first two check the provider receives the restored items before the next turn. The browser media test waits for captured audio and the reply instead of a fixed sleep. The live suite gains a tool and text context round trip across a restart notice. * ci: run the live realtime conversation tests with the AI provider suite They need the OpenAI key, which only this workflow has, and skip themselves without it. * fix(ai): open the replacement before leaving a realtime session on the restart notice The handover closed the current session and then reconnected, so every notice cost a second or two in which sends failed and microphone audio was dropped. On a single server that refuses new sessions while it drains, the retries ran out during the drain and the conversation closed sooner than if it had stayed. The conversation now opens a replacement session as soon as the notice arrives and switches only once it is open and the conversation is idle. A refused replacement is retried with backoff while the current session keeps working; when the server closes it first, the held replacement is used right away. The idle check also missed some in-progress work: - a reply created while the user was still speaking ended the user's turn; speech now lasts until its audio is committed or cleared, and a committed turn waits for the reply server turn detection creates - only the newest function call was checked; every finished call since the last user message needs its output, and calls cut off by a cancellation don't count - native session.update and conversation.item.create events the provider hadn't acknowledged could be lost; the switch waits for them, up to the acknowledgment timeout for items - only the newest transcription was waited on; every committed item is - clearing playback didn't recheck the switch Restored items stay internal: the new session's copies of them no longer reach listeners. At a reconnect, every queued item the user didn't hear is cut, not just the one playing. A notice from a failed reconnect attempt is dropped. * test(ai): realtime replay after split settings, and a draining server refusing the replacement The relay accepts the context replay right after two full-size settings updates. Against the local server, a draining relay refuses the replacement session while the current one keeps answering. * ci: build the SDK in the live realtime step so it stands on its own * fix(ai): a failed realtime reconnect attempt no longer poisons the next An attempt that dropped after sending its session.update but before the acknowledgment left that event ID queued, so the next attempt's acknowledgment was matched to it and timed out, and so on until the conversation closed. Turn state is now reset after each failed attempt. * fix(ai): bound the realtime handover's waits and stop retries that can't succeed - The first replacement opens within a randomized 3 seconds of the notice, so sessions notified together don't all open at once. - A replacement refused with a code retrying can't fix, such as insufficient_funds, ends the attempts; the server's close still falls back to the usual reconnect. - A held replacement that gets an error or its own server's notice is closed and replaced. - A held replacement used after a drop gets a 3 second acknowledgment timeout, since its socket may have died with the network. - close() during the switch closes the held replacement too. - The reply to a committed turn and each transcription hold the switch for at most 10 and 30 seconds, like app items already did, and an app item is released only by an added item of its type, role and call ID. * docs(ai): the realtime restart notice isn't guaranteed before every restart |
||
|
|
c7c3f21e4b |
feat(ai): puter.ai.convo - Preliminary Realtime API implementation (#4401)
* feat(ai): relay OpenAI's Realtime API as puter.ai.assistant
Adds a WebSocket relay at /puterai/openai/v1/realtime on the api host,
one upstream OpenAI connection per client, frames passed through 1:1.
The client socket is accepted before auth so socket.io's 1 s
unhandled-upgrade timeout can't kill slow handshakes; failures arrive as
OpenAI-shaped error events and a 1008 close. Usage is metered per
response.done at the model's token rates, sessions are capped per user
(realtime.maxConnectionsPerUser), and each node logs counters,
event-loop delay and memory every 30 s.
puter.js gets puter.ai.assistant.connect(), returning a session with
send/on/off/close over the same OpenAI events.
realtime.upstreamUrl points the relay at a mock for load tests;
tools/realtime-stress has the mock upstream, a load generator with a
JSON report, and an agent runbook. The relay README lists its quirks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): backpressure and upload budget on the realtime relay
Neither direction applied backpressure: a client that stopped reading
grew a queue up to 64 MiB, and a client streaming 16 MiB frames was read
and forwarded as fast as it could send. A few such sockets pinned the
event loop and pushed RSS past 1 GB.
Each side now stops reading the other once 1 MiB is queued toward it,
and a client that keeps the upstream paused for 30 s is closed. Client
uploads are budgeted per session (1 MiB/s sustained, 16 MiB burst,
realtime.maxClientBytesPerSecond), pre-open buffering pauses reads at
1 MiB instead of rejecting at 32 MiB, and clients get a 30 s ping so
half-open sockets release their upstream and slot. Upstream frames use
a zero mask and skip UTF-8 validation, which the relay never needed.
The stats line gains elu. tools/realtime-stress gains abuse.mjs and a
runbook phase for event-loop abuse.
* fix(ai): drain, transcription metering and credential re-check on the realtime relay
Shutdown now drains: a new onServerDrainStart hook (run when SIGTERM
starts the drain, before the listener closes) makes the relay refuse new
sessions with 1012, open ones keep running through the drain window, and
at onServerPrepareShutdown each gets up to 5 s to finish an in-flight
response before closing with 1012 so the client reconnects elsewhere.
Input transcription is metered. The relay reads the type from each text
frame's first 96 bytes and, after forwarding, parses response.done,
input_audio_transcription.completed and session.created/updated (for the
transcription model) on setImmediate, so metering never holds up frames.
Unknown transcription models bill at the highest known rate.
Credentials are re-checked: a successful auth is cached in the kv.js
singleton for 60 s under a hash of the token, and a 15 s sweep
re-authenticates sessions whose entry expired, closing revoked or
suspended ones with invalid_api_key. Lookup errors keep the session and
retry on the next sweep.
* docs(ai): API shape proposal for puter.ai.conversation() across OpenAI, Gemini Live and xAI
* feat(ai): add composable realtime conversations with bounded transport
* fix(ai): realtime billing, subscription gate and conversation reconnects
Relay:
- Cancel and bill a response still generating when the client leaves,
instead of cutting the upstream before response.done.
- Record realtime usage through the AI cost-factor hook.
- Drop the upstream byte and message caps; replies arrive faster than real
time and tripping them closed sessions and skipped billing.
- Price usage as it arrives, before any forwarding decision, so a closing
session never drops a usage event.
- Bill the model the session reports and refuse a session.update that
changes the model.
- Require a paid plan.
SDK (puter.ai.convo):
- Reconnect after a dropped connection with the same settings, keeping
listeners and media; emits reconnecting and ready { reconnected }.
- Errors reject only the operation they name; a failed send rejects only
its own request.
- configure() sends only the given settings and settles on its own ack;
commitAudio follows turn detection set natively.
- interrupt() right after sendText cancels the reply once it starts.
- Microphone: capture at the device rate, drop chunks instead of stopping
on stalls or a congested socket, no timeout on the permission prompt,
permission_denied on refusal.
- Speaker: 300 s playback bound without interrupting, played position
accounts for output latency and gaps between chunks.
- Audio control events keep headroom over queued audio.
- Upgrade prompt on subscription_required / insufficient_funds.
Tests that need the built SDK and Chromium move to the puter.js suite.
* chore: remove stray node_modules symlinks
* feat(ai): retire assistant API and document realtime beta
* test(ai): wait for the matching realtime billing batch
* test(kv): exclude health probes from warm read measurements
* Revert "test(kv): exclude health probes from warm read measurements"
This reverts commit
|
||
|
|
7401fe0b09 |
fix(notifications): receipts report what the user saw, sticky push opt-in, safer app pushes (#4413)
* fix(socket): count sockets that never acknowledge instead of asking them
askWithAck sent an acked broadcast to sockets outside the ack room only to
learn whether any were there. Those sockets never answer, so every node held
the ack callback until they disconnected. Each node now answers a server-side
count of them instead; a node that does not answer in time still reads as
holding one.
* feat(notifications): read focus and own display from receipt answers
A receipt answer may carry focused and showsOwn beside visibilityState; both
pass through to notif.delivery.settled when they are booleans. An answer with
received: false is not a responder, and an ask answered only that way settles
as unreceived.
* fix(notifications): name an app message's bad field before the size cap
An app message over the 16 KB value cap answered notification_value_too_large
even when one field was what broke a rule of its own. The fields are checked
first now, so the caller gets bad_request naming the field; the total cap still
applies after. Character counts skip the code point walk on strings that can't
fit.
* fix(push-sw): match the server's tags, keep taps, and show pages' notifications
- A push is tagged with the server's group, `<app uid>:<group>` or the uid, and
alerts again when that replaces one showing. Handlers get the app's own group
and the send time in ms from `ts`.
- The app's icon stands in when the notification has none.
- Default and relative tap URLs resolve against the worker's scope, still only
on its origin.
- A tap posted to an open window waits briefly for the page to take it; without
a click handler there, the window is taken to the URL, or one is opened.
- A page that got a notification live while away can ask the worker to show it;
the worker holds it back while another window of the origin has focus.
* fix(notifications): answer every receipt ask
A page now answers each socket that asks about a notification, not only the
first, and answers { received: false } when the notification doesn't arrive
within the wait or the page holds too many asks, so the server's nodes can
let go of them. A received answer carries focused and showsOwn beside
visibilityState.
* fix(notifications): keep web push to users who turned it on, and show what a push would
Subscription:
- onPush() renews the subscription on load only for a user who turned push on
on this origin with enable(); enable() records that, and disable(), sign-out
and push_revoked forget it. One renewal runs per page and worker, however many
onPush() calls there are, and push_disabled, push_revoked, push_unavailable
and too_many_requests stay quiet there.
- puter.auth.signOut() on a website removes this browser's subscription with
the leaving user's token and forgets the opt-in and kept taps, without
holding up the sign-out.
- A subscription another service on the site made is left alone and rejected
as push_subscription_conflict; one made with Puter's earlier key is replaced.
- A subscription refused with push_endpoint_revoked is replaced once, like
push_subscription_invalid.
- Another worker holding the scope rejects push_worker_conflict, naming
importScripts and the serviceWorker option, instead of waiting out the
confirmation push.
- enable() rejects user_gesture_required outside a click instead of prompting,
and a refused browser subscribe rejects push_subscribe_failed.
Live side:
- A page that is hidden or unfocused shows every app notification it receives,
with or without a handler for its channel, through push-sw.js so a focused
window of the app holds it back. It uses the server's tag, the app's icon as
a fallback, the generic app line for sensitive ones, and the registration of
its own worker.
- Page handlers get 5 seconds, as in the worker (push_handler_timeout).
- A feed stopped by a sign-out starts again on the next sign-in.
- onPush() still delivers live when IndexedDB is unavailable.
- onPush() rejects invalid_handler for a handler the worker could not rebuild
from its source (methods, bound and built-in functions).
- Saving and removing a handler's source for the worker is ordered per key, so
a quick unmount and remount keeps it.
- A tap posted by push-sw.js is acknowledged only when a click handler takes it.
* fix(gui): show app pushes under the app's name and Puter's icon
- On Puter's own origin, an app's push takes the app's name as its title, with
the app's title and text in the body, and Puter's copy of the app's icon; an
icon the app chose is never shown. A sensitive one keeps its generic line.
- A push's ts is its timestamp, and a newer push in a group alerts again.
- Re-registering this browser counts only apps that push to Puter's devices
(puterDevices), so a website's Web Push confirmation alone never registers
it.
* fix(gui): free the push dialog's button when registering fails late
A registration that failed after the dialog's deadline left the button
spinning and disabled. It now clears the attempt, settles on a state the
browser moved to meanwhile, or offers a retry.
* fix(permission): a user's own app grant clears a narrower via on the row
An app grant that already existed was skipped, so a row another path recorded
with extra.via kept it after the user approved the same permission in Puter.
A grant without via now rewrites such a row without it, keeping its other
extras; a grant with via still never touches an existing row.
* refactor(notifications): export the notification text cleaner
Extensions that word notifications can use the same cleaning as app messages.
* docs(notifications): describe when pushes go out, taps, worker rules and enable() errors
Pushes go out when no open page answers that it's visible and focused or
shows the notification itself. Documents the page-shown notification, the
sticky opt-in, tap routing, group and sensitive behavior, service worker
rules, requirements, every enable() and onPush() error, push caps for
unlisted apps and websites, and sending from workers and events handlers.
The push playground example is removed (the playground can't serve the
site's service worker) and redirects to the guide. The minimal page catches
enable() errors and sends after 5 seconds. The recipe no longer sends on
every load, and app.message is listed in events fetch and the catch-up
recipe.
* docs(notifications): per-app browser caps and when push_endpoint_revoked appears
* docs(notifications): the page's handler timeout
* fix(notifications): leave a site's own service worker alone when enabling pushes
Registering /puter-sw.js at a scope another worker holds queued it behind that worker, so enable() rejected with push_worker_conflict but swapped the site's worker out once its tabs closed. Now enable() checks the scope first and rejects without registering.
* docs(notifications): a background page shows score alerts itself
|
||
|
|
61b713438d |
docs(notifications): push setup requirements, browser and device support, and push recipes (#4406)
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Adds worker requirements, per-browser/OS support (iOS Home Screen, macOS/Windows/Android settings), and troubleshooting to the push setup page. Website taps now documented on on('click'). New recipes: notify chat members of new messages, alert users when shared data changes.
|
||
|
|
7ceaf69651 |
docs(notifications): import push-sw.js from the root of js.puter.com (#4405)
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
The worker is published next to puter.js at https://js.puter.com/push-sw.js. Docs and the worker's own header now point there. The js subdomain serves /push-sw.js for self-hosted setups and keeps /v2/push-sw.js as an alias. |
||
|
|
c00f693ec6 |
feat(notifications): report how each live notification was received (#4403)
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
* feat(notifications): report how each live notif.message was received SocketService.sendWithAck sends an event to rooms or sockets and waits, up to a timeout, for an acknowledgement from each socket that listed the event in its handshake (`auth.acks`); every other socket gets a plain emit. It lists sockets across the cluster with fetchSockets and asks the listed ones through the adapter's broadcast acks, and counts this node alone when the adapter cannot list the others. The emitting region now sends notif.message through it, with the window from `notificationAckTimeoutMs` (default 5000), and emits the node-local `notif.delivery.settled` with the outcome: received, unreceived, no-socket or remote-only (sockets only in a peer region), whether any socket there acknowledges, and each responder's visibilityState. Peer regions keep sending their own copy as before; the GUI event carries `region_delivered` so this region does not send it twice. The desktop and dashboard list notif.message in their handshake and answer its callback once with document.visibilityState. Nothing in core acts on the settled event. * fix(notifications): send live notif.message at once and report what the count covers sendWithAck now emits to the whole room straight away as a broadcast with ack, and lists the room's sockets alongside it only to fill in the counts, so nobody waits on the listing. Whether the adapter can count the cluster's servers is checked once; where it cannot, sends go out per socket on this node with a one-time warning. The result carries `complete`: false when only this node's sockets were counted, either for that reason or because the listing failed or took over 1 s, which is warned about at most once a minute. notif.delivery.settled gains `sendId`, `type`, `complete` and `peerRegions`. An incomplete count with no socket found settles as unreceived rather than no-socket, and the peer-region read runs alongside the send instead of after it. notif.written carries the same `sendId`, minted per send (an update gets a new one) and passed through notif.created to the socket adapter on the fold-in path. The comment on revoking sockets no longer says fetchSockets is unavailable with our Redis client; it works with ioredis cluster. * fix(notifications): keep a failed server count from ending the process socket.io awaits the adapter's serverCount without a catch on every broadcast with an ack, and the redis-streams adapter's count rejects as soon as one cluster node fails to answer. The count on the root namespace's adapter now never rejects: a failure reports the last good count (1 before any), and is warned about at most once a minute. A stale count only lets an ack wait run to its timeout. Whether sendWithAck takes the cluster path is no longer a one-shot check awaited before the emit. Until a count has succeeded, sends go out per socket on this node at once while a count is retried in the background, at most every 30 s (SocketService.COUNT_RETRY_MS); the first success switches to the cluster path for good. * fix(notifications): ask only acknowledging sockets, through an ack room A socket whose handshake lists an event in `acks` now joins `<room>:acks:<event>` for each of its rooms and its own id. sendWithAck sends the ordinary broadcast to the target rooms except those ack rooms, which keeps it on the adapter's stream, and a broadcast with an ack only to the ack rooms. Sockets that never acknowledge are no longer asked, so other nodes hold no pending ack for them and a send completes as soon as every acknowledging socket answered. The per-socket listing that matched socket.data.acks is gone; the ack-capable count comes from the room membership the listing already returns. The per-socket path stays only for a node whose adapter has not counted servers yet. A failed server count during a send now marks its counts incomplete, so a stale count can't make a listing that missed a node read as complete. With no count ever succeeded (an in-memory Redis), the failure is warned once per process instead of once a minute. * fix(notifications): say whether every socket on a live notification acknowledges it A tab on an older bundle beside one that acknowledges made the settle read as ack-capable, so a listener could not tell that the older tab may have shown the notification. notif.delivery.settled now carries everySocketAcks. * feat(notifications): app pages acknowledge the live copy of an app message NotificationService.appReceipt() asks an app's own sockets whether a notification reached a page, and settles it as notif.delivery.settled with appUid, the same outcomes as the desktop's. Callers ask only when the answer decides something, since it is a round trip to every node. - SocketService.askWithAck asks without listing sockets: sockets that don't acknowledge get an acked broadcast too, which can only time out, and that says whether an older client shares the target. - puter.js declares notif.receipt on its filesystem and events sockets. A page answers once one of its notification feeds received that uid, before any handler runs, and never when no feed on the page got it. - Docs describe the push route as "no page of the app received it live within 5 seconds". * fix(notifications): count a socket that acknowledges other events as a current client Every desktop page also holds puter.js's own socket, which never shows notif.message, so "every socket acknowledges notif.message" never held there. sendWithAck now reports `declaring`, the sockets that listed any event in auth.acks, and everySocketAcks on the desktop's settle means no socket here declares nothing. * fix(puter-js): fall back to the Continue dialog when a popup can't be opened Some browsers report user activation but still return null from window.open (iOS home-screen web apps, Safari after awaited work), so permission prompts, signIn, enablePush, and GUI action popups failed without showing anything. PuterDialog.launch() now owns the open-now-or-show-Continue decision for every popup flow and shows the consent dialog when the direct open returns null. On cross-origin-isolated pages signIn leaves settling to the /login/wait relay on both launch paths. * fix(notifications): show a live push when the page's window isn't focused onPush showed the handler's result as a system notification only when the tab was hidden. A visible tab in a window the user left showed nothing. It now also shows when the document doesn't have focus. Inside the Puter desktop the desktop's banner shows the notification, so an app's frame still never does. * build(puter-js): publish push-sw.js exactly as written The build emits dist/push-sw.js next to puter.js, but terser minified it along with the bundle. It is now marked as already minimized, so the file apps import into their service worker is the source unchanged. * fix(notifications): send live notif.message plainly and ask for receipts on request Every live notif.message used to be sent with an ack, list the user's sockets across the cluster (fetchSockets ships each socket's handshake between nodes) and wait out the receipt window, whether or not anything would act on it. notif.message is a plain send again. NotificationService.receipt() asks the desktop's sockets, or with appUid an app's own, the way app receipts already did: an ask with no socket listing, settled as notif.delivery.settled. A caller asks only when the answer changes what it does. - The desktop and dashboard sockets answer notif.receipt from the page's puter.js receipt log and note each live notif.message in it, so every socket on the page answers alike. A page whose puter.js has no log lists no acks. - SocketService.sendWithAck, the socket listing and region_delivered are gone. askWithAck counts this node's sockets when the adapter cannot count servers. - The send id stays on notif.written and the settle, and leaves the wire. * fix(socket): join only the ack room receipts are asked in A socket listing events in auth.acks joined an ack room beside each of its rooms and its own id. Receipts are only asked in the user's room, or the app's for an app socket, so that is the one ack room it joins. * docs(notifications): publish the push limits of 12 a minute and 1,000 a day Pushes are capped per user per app at 12 a minute and 1,000 a day, and at 6 a minute and 200 a day while an app is new. The rate limits table and the notification pages said one a minute and 100 a day. --------- Co-authored-by: Daniel Salazar <daniel.salazar@puter.com> |
||
|
|
dace77c212 |
fix(gui): a request the server no longer accepts signs the tab out locally (#4390)
release-please / release-please (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
A 401 outside the reauth flow used to run the full logout, which ends the session on the server and takes every device registered under it. The tab now drops its token and reloads instead; the session cookie signs it back in when it still holds. A second 401 right after that reload falls back to the full logout. A peer tab signing out is mirrored the same way. |
||
|
|
8e1233d82a |
feat(push): notifications:push app permission (#4317)
* feat(push): notifications:push app permission Apps need a per-app notifications:push grant for their notifications to go out as pushes. hasPushConsent checks it as the app acting for the user, so only that user's grant to that app answers yes, and a revoke takes effect on the next check. The permission dialog describes it and the perms docs list it. * fix(push): canonical app uid in the push consent check * fix(push): let users grant apps notifications:push The grant route now only accepts permissions from a fixed set of app-grantable families, which refused notifications:push, so an app could never be given push consent. Register it next to events:background. * refactor(permission): keep the notifications:push constant with the permission code The consent check that reads it lives with the delivery code, outside core. The grant flow test checks the grant through the permission service directly. * feat(notifications): node-local notif.written hook NotificationService emits notif.written after each non-silent write and after an in-place rewrite, on the node that wrote the row, so an extension can act on new notifications without a second bus round trip. It stays off the published event stream; notif.created carries the notification itself. * feat(puter-js): puter.notifications An app notifies the user it runs for, reads its own notifications back and follows new ones, hears clicks on them from the desktop, and asks for or checks the notifications:push grant. Puter's own apps also register, list and remove the user's devices. * feat(puter-js): device calls report push_unavailable without a push service A deployment with no push service has no /push routes, so core answers the device calls with its catch-all 404. registerDevice, listDevices and removeDevice turn that into push_unavailable, kept apart from a missing device, which comes back as device_not_found. * feat(homepage): serve the service worker at /sw.js /sw.js serves the GUI's sw.js, preceded by one importScripts per script an extension registered with registerServiceWorkerScript. Whether the GUI registers the worker follows the pushEnabled gui param, which only an extension sets. Notification reads also carry the row's app_uid, so the GUI can route a tap to the app a notification is about. * feat(gui): notifications settings for push devices and apps Settings -> Notifications turns pushes on and off for this browser through a push client an extension registers, lists the user's devices and the apps allowed to push, and switches Puter's own pushes. The tab shows only when the pushEnabled gui param is true. The sessions window shares the user agent formatting with it. * feat(gui): service worker that shows pushes and routes taps sw.js shows each push as a notification and, on a tap, focuses or opens a Puter window and hands it the tapped notification. The desktop routes the tap to the app the notification names, opening it when needed, or to the notifications panel. The worker is registered only when the pushEnabled gui param is true. * feat(gui): notifications settings read a missing push service as unavailable Without a push extension the /push routes answer core's plain 404. The GUI maps that to push_unavailable, so the tab says pushes aren't available on this server instead of reporting a load failure, and a device removal only counts as done when the device itself is missing. The push client doc describes the payload sw.js reads and where the push endpoints come from. * fix(gui): keep the service worker on servers without push Unregistering /sw.js wherever pushEnabled is unset ended the browser's push subscription, so a single page load on such a server stopped pushes from every server. Turning push off already ends the subscription. * feat(gui): the service worker tells open tabs when the push subscription changes * fix(gui): removing this browser in Settings also forgets its push registration * fix(gui): removing this browser also deletes it on the server when its local record is gone * fix(notifications): notif.written hands out a copy with the audience Listeners get their own copy of the value, so what the socket carries is what was written. Both emits name the audience, and an update names the row's app when the row or the value says which. * fix(notifications): read the app off the notification on an update * fix(gui): route push taps through the stored notification A tap from the worker names only a notification; the desktop and the dashboard read the signed-in user's own row and route from its app, target and data, as a tap from the URL already did. Without a row the dashboard opens the list and the desktop does nothing. * fix(gui): local banner tags carry the app An app message's group is tagged under its app, as the server tags a push, so two apps using the same group name keep separate banners. * fix(gui): a tap delivered twice routes once The page remembers the notifications it handed over in the last ten seconds and drops a repeat, whichever way it arrives. * fix(gui): the service worker never reuses cached imports The worker is registered with updateViaCache set to none, so the scripts it imports are fetched fresh on every update check. * fix(gui): turn on can't run twice at once The browser card's button stays out while the client is enabling or disabling, through any redraw in between, and a status read begun before the press no longer draws the card. * feat(gui): turn on browser push when an app is allowed to notify Allow on a permission request that includes notifications:push also turns push on in this browser, from the same click, when it is off here. The decision never depends on it. The permission popup starts the service worker for such a request, answers the opener first and closes once the registration is done. The Notifications tab records a browser the user turned off, per user. * feat(gui): register this browser for pushes again where it already allows them On desktop and dashboard boot, and when an app that holds notifications:push is launched, a browser whose notification permission is already granted registers again without a click, unless the user turned push off in this browser. * feat(sdk): puter.notifications.enablePush() and the enable-push popup An app on its own domain can offer to turn push on in the user's browser from its own button: puter.notifications.enablePush() opens a popup on the Puter origin whose single button supplies the click the browser's prompt needs, and inside the desktop the same dialog is shown over IPC. The popup hands the opener no token. Docs for the new call and for the paths that now call the push client's enable(). * fix(gui): wait for the browser's prompt before closing the permission popup The deadline on a registration begun from a click counts no time while the browser's own notification prompt is open, in the permission popup and in the enable-push dialog. The enable-push dialog keeps one registration in flight, settles on whenever it resolves while open, and offers a retry only after a real failure. A permission dialog that ends in a denial after Allow began a registration turns push back off and restores the opt-out it cleared. * fix(gui): one enable-push dialog at a time, only for apps that may send pushes The desktop and the popup show the enable-push dialog only when the asking app holds notifications:push. The IPC branch answers a second call unavailable while a dialog is up, as the feedback dialog does. The silent registration on boot and app launch runs once at a time. A popup whose opener cannot be attested answers the enable-push request before it closes. * feat(events): isOnline presence check for a user and app * feat(notifications): onPush and enable with Web Push on the app's own origin puter.notifications.onPush(key, handler) handles an app's notifications for one channel: live over the events socket while the app is connected, and as a browser push otherwise. enable() asks for notification permission on the site's origin and subscribes through the site's worker, which imports push-sw.js (served at js.puter.com/v2/push-sw.js). A new subscription is confirmed by a challenge push the worker hands back to the page. disable() removes it. The GUI uses the same flow through /sw.js when the server publishes pushWebPushKey. * fix(notifications): visible sender for app messages; local banners follow push consent An app title of only invisible or blank-filler characters no longer leaves an app message without a sender; it falls back to the app's name, then its uid. Hidden-tab banners now show only while push is on in this browser, and for an app's message only when the app holds notifications:push. * feat(notifications): enable() asks through Puter's prompt after a revoke When the user turned an app's notifications off in Puter, the browser's permission on the app's origin no longer re-enables them. enable() asks again through Puter's permission prompt and retries once. * fix(notifications): enable() rejects on a revoke, onPush() syncs quietly, one permission check per app a minute enable() no longer opens Puter's prompt after a push_revoked error; apps call requestPermission() from a click and enable() again. onPush()'s page-load sync no longer reports push_unavailable or push_revoked. Hidden-tab banners for an app's messages check the app's push permission once a minute instead of once per message. * docs(notifications): describe the confirmation step as what happens; one-line comments and neutral fixtures * docs(notifications): guide for app push notifications, limits and prices * fix(puter-js): subscribe once the worker is active, and retry a subscription the push service hasn't settled * fix(gui): the desktop's hidden-tab banner is for Puter's own notifications only * docs(notifications): app pushes at $2.50 per million --------- Co-authored-by: Daniel Salazar <daniel.salazar@puter.com> |
||
|
|
722c25e0dc | Merge juancastro/put-2258-push-from-app-subdomains into juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes | ||
|
|
9e449e635b |
Merge pull request #4399 from HeyPuter/juancastro/put-2180-push-docs
docs(notifications): guide for app push notifications, limits and prices |
||
|
|
ba7011b5bb |
Merge pull request #4391 from HeyPuter/juancastro/put-2258-push-from-app-subdomains
feat(gui): turn on browser push when an app is allowed to notify |
||
|
|
25d5843d03 | Merge juancastro/put-2262-web-push into juancastro/put-2180-push-docs | ||
|
|
4e6d655f4c | Merge juancastro/put-2258-push-from-app-subdomains into juancastro/put-2262-web-push | ||
|
|
bd1dd6564e | Merge juancastro/put-2172-notifications-settings into juancastro/put-2258-push-from-app-subdomains | ||
|
|
69a3e918a7 | Merge juancastro/put-2171-push-service-worker into juancastro/put-2172-notifications-settings | ||
|
|
aded055212 | Merge juancastro/put-2170-puterjs-notifications into juancastro/put-2171-push-service-worker | ||
|
|
e54750492f | Merge juancastro/put-2167-notification-written-hook into juancastro/put-2170-puterjs-notifications | ||
|
|
406fe4dcfc | Merge juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes into juancastro/put-2167-notification-written-hook | ||
|
|
2e085c0b75 | Merge juancastro/put-2251-let-apps-send-notifications-to-their-own-user into juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes | ||
|
|
b95fb870a5 | Merge main into juancastro/put-2251-let-apps-send-notifications-to-their-own-user | ||
|
|
28be1851f9 |
fix(workers): bind credentials and cleanup to the owning site (#4362)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
* fix(workers): bind credentials and cleanup to the owning site * test(workers): deploy valid source in the site-bound token test Where workerd is installed the deploy starts the script, and the bare identifier `src` threw at startup as an unhandled rejection. |
||
|
|
ff83360272 | docs(notifications): app pushes at $2.50 per million | ||
|
|
b19fde242f |
fix(networking): recover shared relay clients and preserve write order (#4377)
* fix(networking): recover shared relay clients and preserve write order * fix(networking): clear the flush flag with the last queue check A write landing between the drained queue and the connect path resuming was queued with nothing left to send it. * fix(networking): keep a throwing socket listener out of connect and close An open listener that threw was caught as a failed connect and could retry onto a second stream; an error listener that threw stopped the socket from ever closing. Both now rethrow on their own tick. |
||
|
|
c2b38786f7 |
fix(ai): meter converted audio from reported output duration (#4366)
* fix(ai): meter converted audio from reported output duration * fix(ai): size undocumented voice changer output formats as the default * fix(ai): skip WAV headers when metering and renew streamed charge holds A wav_* output's RIFF header was counted as samples, rounding a 3 s clip up to 4 s. The streamed-charge hold now renews until the charge lands, so a slow reader can't outlast it. * fix(ai): time Opus output by its pages and renew holds through slow opens Opus is variable bitrate, so a mostly silent clip billed far under its length at the nominal rate; its length now comes from the last Ogg page's granule position. The streamed-charge hold renews from before the upstream opens, since opening can run as long as the hold's TTL, and is released even if recording the charge throws. Speech-to-text extra_body can no longer override response_format or stream, which billing reads. |
||
|
|
7c6c0f2d39 |
fix(ai): finish async media accounting and recheck output access (#4365)
* fix(ai): finish async media accounting and recheck output access * fix(ai): settle late media jobs on their outcome and keep their slot A job still running when the poll window closes no longer charges its estimate up front. The caller still gets the 504; the job is followed in the background, holding its credit, until it settles, and is metered from the provider's outcome: what it reports, nothing for a failure that reports no cost, the estimate only if it never settles. The driver concurrency slot is now held until the handler and any work it carries on after the response settle, so a disconnect no longer frees a slot while polling continues. A revoked puter_output_path is reported as metadata.outputSaved = false. * fix(ai): bill a lost media job the same in and after the window A poll error that isn't transient now ends the same way whether it hits inside the window or after it: a client-error refusal is a failure the provider doesn't bill, while auth, not-found and unclassified errors mean we lost a job the provider still runs, charged its estimate. A Hailuo clip already billed retries a transient file lookup instead of failing, and a concurrency slot renews from the moment it is held. |
||
|
|
5ac41f90b6 | Merge juancastro/put-2262-web-push into juancastro/put-2180-push-docs | ||
|
|
c024e007aa | Merge juancastro/put-2258-push-from-app-subdomains into juancastro/put-2262-web-push | ||
|
|
eeedeb817f | Merge juancastro/put-2172-notifications-settings into juancastro/put-2258-push-from-app-subdomains |