mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efc71a5537 | ||
|
|
97ea3ca26f | ||
|
|
066b37a3f9 | ||
|
|
0ec3d45407 | ||
|
|
f2263ff5cf | ||
|
|
55354594b2 | ||
|
|
bfdc10ac4e | ||
|
|
f44ca5daa2 | ||
|
|
817977cff0 | ||
|
|
e48e5c70c8 | ||
|
|
c091c2e9b1 | ||
|
|
5f03613716 | ||
|
|
3ff70ef97f | ||
|
|
f791024b2e | ||
|
|
cce9718b45 | ||
|
|
9758ada36e | ||
|
|
37dcee78f0 | ||
|
|
162398a827 | ||
|
|
6785b4d9cc | ||
|
|
d2acdafaed | ||
|
|
6a46363d6d | ||
|
|
2de9d509e8 | ||
|
|
763742dbc0 | ||
|
|
7f0cd1689b | ||
|
|
9d22f39928 | ||
|
|
ef185c04ca | ||
|
|
dec7d1fd96 | ||
|
|
fb9e7d70c6 | ||
|
|
819e77bb4c | ||
|
|
7355eaa77b | ||
|
|
d55aac1f98 | ||
|
|
c6987d9606 | ||
|
|
796b42c74d | ||
|
|
e2043a5e58 | ||
|
|
8b98c7ada8 | ||
|
|
b27e84d181 | ||
|
|
add0e7be7d | ||
|
|
b5d221ede6 | ||
|
|
3137a631a7 | ||
|
|
57706d8093 | ||
|
|
fec1d1916f | ||
|
|
da369039e0 | ||
|
|
4f36152850 | ||
|
|
86db96d587 | ||
|
|
214ffb6605 | ||
|
|
871cc2cf87 | ||
|
|
ba002be0f7 | ||
|
|
2832926f8d | ||
|
|
1ab1c04760 | ||
|
|
63a98f89ff | ||
|
|
d2cef27085 | ||
|
|
74b60c1227 | ||
|
|
2ed3e78dfa | ||
|
|
22ebd353b8 | ||
|
|
7551e12370 | ||
|
|
6e29e32fe9 | ||
|
|
54bfc0ffe5 | ||
|
|
c27883134b | ||
|
|
cf4c1f6b64 | ||
|
|
305d20ab1c | ||
|
|
0ce6b0646a | ||
|
|
6b5c4f3d9e | ||
|
|
d6fdc5f268 | ||
|
|
4f3f946941 | ||
|
|
4e36672b6b | ||
|
|
6b0b530ab1 | ||
|
|
68b8f341e9 | ||
|
|
c3756d3ac3 | ||
|
|
0c927ac5a4 | ||
|
|
64c3b3c0ce | ||
|
|
e9a4c7a461 | ||
|
|
be9cd9eb89 | ||
|
|
8b64cc5076 | ||
|
|
d3869b7846 | ||
|
|
91230697cd | ||
|
|
56e4856518 | ||
|
|
8acd73675c | ||
|
|
fc4af97885 | ||
|
|
7cec710014 | ||
|
|
48cd4ea355 | ||
|
|
a110be0340 | ||
|
|
93798cba93 | ||
|
|
848e336455 | ||
|
|
a9a8df6409 | ||
|
|
0a8b37ae56 | ||
|
|
23782dec12 | ||
|
|
6a6cf78c03 | ||
|
|
8264837d9f | ||
|
|
eb92421a59 | ||
|
|
e0af3361ed | ||
|
|
9bd7ba7079 | ||
|
|
8f3d4cc40f | ||
|
|
96d91cd74e | ||
|
|
cdb3110857 | ||
|
|
d934a47c5e | ||
|
|
0128e8779d | ||
|
|
89f070f7d0 |
No files matched your search
@@ -0,0 +1,48 @@
|
||||
name: Notify HeyPuter (dev)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
puter_ref:
|
||||
description: "puter branch, tag or SHA"
|
||||
type: string
|
||||
default: "main"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
if: github.repository == 'HeyPuter/puter'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require maintain or admin
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ACTOR: ${{ github.triggering_actor }}
|
||||
run: |
|
||||
ROLE=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" --jq .role_name)
|
||||
if [ "$ROLE" != "maintain" ] && [ "$ROLE" != "admin" ]; then
|
||||
echo "::error::$ACTOR has role '$ROLE'; maintain or admin required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
notify:
|
||||
needs: authorize
|
||||
runs-on: ubuntu-latest
|
||||
# Holds HEYPUTER_DISPATCH_TOKEN; limited to main so branch copies of this file can't read it.
|
||||
environment: heyputer-dispatch
|
||||
steps:
|
||||
- name: Trigger heyputer dev deploy
|
||||
env:
|
||||
PUTER_REF: ${{ inputs.puter_ref }}
|
||||
TOKEN: ${{ secrets.HEYPUTER_DISPATCH_TOKEN }}
|
||||
run: |
|
||||
jq -nc --arg ref "$PUTER_REF" \
|
||||
'{event_type: "puter-dev-requested", client_payload: {puter_ref: $ref}}' |
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
https://api.github.com/repos/HeyPuter/heyputer/dispatches \
|
||||
-d @-
|
||||
echo "Requested \`$PUTER_REF\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
environment: heyputer-dispatch
|
||||
steps:
|
||||
- name: Trigger heyputer build
|
||||
run: |
|
||||
@@ -15,4 +16,4 @@ jobs:
|
||||
-H "Authorization: token ${{ secrets.HEYPUTER_DISPATCH_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
https://api.github.com/repos/HeyPuter/heyputer/dispatches \
|
||||
-d '{"event_type":"puter-main-updated","client_payload":{"puter_ref":"main"}}'
|
||||
-d '{"event_type":"puter-main-updated","client_payload":{"puter_ref":"main"}}'
|
||||
@@ -176,7 +176,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# Missing when the PR leg failed; `coverage (pr)` already reports that.
|
||||
- name: Download PR coverage
|
||||
id: pr-coverage
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: puterjs-coverage-pr
|
||||
@@ -186,6 +189,7 @@ jobs:
|
||||
# base run) — then report without the comparison column.
|
||||
- name: Download base coverage
|
||||
id: base-coverage
|
||||
if: steps.pr-coverage.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -193,7 +197,7 @@ jobs:
|
||||
path: coverage-base
|
||||
|
||||
- name: Report coverage on PR
|
||||
if: steps.base-coverage.outcome == 'success'
|
||||
if: steps.pr-coverage.outcome == 'success' && steps.base-coverage.outcome == 'success'
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
name: puter.js SDK
|
||||
@@ -202,7 +206,7 @@ jobs:
|
||||
json-summary-compare-path: coverage-base/coverage-summary.json
|
||||
|
||||
- name: Report coverage on PR (no base comparison)
|
||||
if: steps.base-coverage.outcome != 'success'
|
||||
if: steps.pr-coverage.outcome == 'success' && steps.base-coverage.outcome != 'success'
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
name: puter.js SDK
|
||||
|
||||
@@ -159,13 +159,42 @@ The reference has two kinds of page:
|
||||
|
||||
An overview page answers "what is this API and is it what I need?", and a function page answers "how exactly does this call behave?". A change to one function usually only touches that function's page, so a new `kv.list()` option goes in `KV/list.md` without a matching update to `KV.md`. Don't copy a function's options, limits, or edge cases onto the overview page for completeness. Where a detail goes is a judgment call, not a hard rule: update the overview when a change affects how someone would describe the API as a whole.
|
||||
|
||||
When you add a page, add it to [src/docs/src/sidebar.js](src/docs/src/sidebar.js) under its area, or it won't show up in the navigation. When you add a runnable example, add its HTML to `src/docs/src/playground/examples/<slug>.html`, register the slug in [src/docs/src/examples.js](src/docs/src/examples.js), and link the code block to it by putting the slug after the language with a semicolon (` ```html;kv-set `).
|
||||
When you add a page, add it to [src/docs/src/sidebar.js](src/docs/src/sidebar.js) under its area, or it won't show up in the navigation.
|
||||
|
||||
**Sidebar order.** Order the API areas in the sidebar by how often developers use them, most common first. Overview stays first and Objects stays last. Inside an area, order the pages by use too, and keep pages that make up one feature next to each other, such as `share()`, `unshare()`, `listShared()`, and `listSharedByMe()`. A group goes where its most-used page would go. A helper that only supports another function, such as `listModels()` for `chat()`, goes by its own use. Put create, list, get, update, and delete in that order, and constants such as `MAX_KEY_SIZE` last. The function list on the area's overview page uses the sidebar's order. When you add a runnable example, add its HTML to `src/docs/src/playground/examples/<slug>.html`, register the slug in [src/docs/src/examples.js](src/docs/src/examples.js), and link the code block to it by putting the slug after the language with a semicolon (` ```html;kv-set `).
|
||||
|
||||
### Function page layout
|
||||
|
||||
A function page opens with one sentence that says what the function does, in its own paragraph, such as "Given a prompt, generate an image using AI." Put any more detail in the paragraphs after it.
|
||||
|
||||
After the intro, a function page has these `##` sections, in this order:
|
||||
|
||||
1. Syntax
|
||||
2. Parameters
|
||||
3. Return value
|
||||
4. Errors, if the function has any
|
||||
5. Sections for specific topics, if the page needs them, with `## Model-specific features` last
|
||||
6. Examples
|
||||
|
||||
[chat.md](src/docs/src/AI/chat.md) follows this layout.
|
||||
|
||||
- **Options as a table.** Document an options object as a table with Option, Type, and Description columns. Give each option its own row, even when two options go together, such as `width` and `height`. Order the rows by how often developers use them, most common first, without group rows. In chat.md that's the core options, then reasoning, tools, response handling, request control, and output.
|
||||
- **Common options in the table, model-specific behavior in its own section.** A page whose function works with many models or providers, such as [chat.md](src/docs/src/AI/chat.md) and [txt2img.md](src/docs/src/AI/txt2img.md), splits its options this way:
|
||||
- **The options table** under Parameters lists the options that work with every model, and describes each one the way it works for every model.
|
||||
- **`## Model-specific features`**, the last section before Examples, covers everything else. It has a `###` for each model family or provider, named after it, such as "OpenAI models" or "Cloudflare models". Under it go the options only some models accept, and the common options a model handles differently, such as a narrower range, other allowed values, or an option it ignores.
|
||||
- **Write there only what's different.** A `###` lists an option only when that model or provider adds it or handles it differently. An option that works the same everywhere stays out, even if the provider supports it, and a model or provider with nothing different gets no `###`.
|
||||
- **Model-specific features is the only place for model-specific details.** Every limit, format, allowed value, default, price, or quirk that applies to some models goes there, in that model's or provider's `###`, and nowhere else. The options table and topic sections (such as Image inputs or Video input) describe what's true for every model and link to Model-specific features for the rest. Don't add a table or sentence there that compares providers or repeats a model's limits. Each copy is another source of truth to keep in sync, and copies drift when a model changes.
|
||||
- **Link the two.** Directly under the options table, put `Some models accept additional options or handle these options differently. See [Model-specific features](#model-specific-features).` A row whose option behaves differently on some models can also end with `See [Model-specific features](#model-specific-features).`
|
||||
- **A long topic gets its own `##` section.** When explaining an option or feature takes more than a sentence or two, such as function calling or output dimensions, give it a section between Errors and Examples and link to it from the options table. This keeps the reference sections short.
|
||||
- **Usage patterns are examples.** A way of using the API, such as stopping a stream, goes in a runnable example that the relevant section links to ("See the [stop a stream example](/playground/ai-chat-stop-stream/)."), not in a section of its own.
|
||||
- **Each example has a `###` heading** under `## Examples`, so every example shows in the table of contents.
|
||||
|
||||
### Writing docs
|
||||
|
||||
These apply to reference pages and recipes alike.
|
||||
|
||||
- **Document the developer-facing side only.** The reader is building on top of puter.js, so cover what affects their code: signatures, defaults, return shapes, errors, limits, and behavior they can observe. Leave out internal workings (which service handles the call, storage layout, caching, retries) unless they change what the developer has to do. [rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) is the exception: it publishes every limit, including timeouts and caps that come from how the backend works.
|
||||
- **Document Puter as it runs on puter.com.** The docs are for developers building on production Puter. Leave out behavior that only happens on a self-hosted or differently configured server, such as a provider that isn't set up there.
|
||||
- **Describe the current state.** Docs aren't a changelog. Write how the API behaves now, without "now", "no longer", "existing code is unaffected", or other wording that compares it to an older version.
|
||||
- **Verify before you document.** Run the calls you describe against a running Puter instead of inferring behavior from the source.
|
||||
- **Write plainly.** Use the literal words a developer would say out loud, with contractions and everyday words. No idioms or figurative verbs ("walk away from something shared with you" → "remove your own access", "nothing to page through" → "without pagination"). No formal register ("present only while more pages remain" → "includes a `cursor` if there are more pages to load"). Prefer dev terms such as atomic, concurrent, and insertion order.
|
||||
@@ -173,6 +202,7 @@ These apply to reference pages and recipes alike.
|
||||
- **Describe behavior directly.** No quoted inner-voice phrases. Write "`isNew` is `true` when the person did not have access before", not "this tells 'I just gave this to someone' from 'they already had it'".
|
||||
- **Keep references easy to follow.** Name what a list or code block belongs to in the sentence directly above it. Name a thing once and keep that name. Introduce an actor as "a user" before giving them a role: "A user can create a team. The user who creates the team becomes its owner."
|
||||
- **Sentence mechanics.** Never start a sentence with inline code: write "To store data, use the [`set()`](/KV/set/) method." Link every inline mention of an API to its page, every time. No em dashes, and no colons or semicolons mid-sentence; a colon that introduces a code block is fine.
|
||||
- **No `##` or `###` headings inside tabs.** The table of contents lists every `##` and `###` heading on the page, including the ones in a hidden tab (an `example-content` block switched by the `example-group` pills), so its links lead to content the reader can't see. Put the heading above the tabs, or use `####` inside a tab, which the table of contents skips.
|
||||
- **Show the correct way.** No "don't" and no wrong-way-first examples, in recipes and playground examples alike. Mention a wrong approach only when developers already hit it in practice, and state how it fails as a fact: "`add()` reads a plain object as a path map, so the call rejects with …".
|
||||
|
||||
### Recipes
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# Embedded app browser
|
||||
|
||||
`GET /embed/apps` on the deployment's main GUI origin renders the same app browser
|
||||
as the dashboard's Apps tab, without the desktop or dashboard navigation.
|
||||
|
||||
It supports search, paging, saved folders, and saved ordering. It uses the same
|
||||
installed-app and recommendation data as the dashboard, including the user's
|
||||
removed recommendations. Editing, adding, reordering, renaming, and uninstalling
|
||||
are disabled. Selecting an app opens it in a new browser tab at its own URL, the
|
||||
same way the dashboard does, and the host page stays open. The app's token isn't
|
||||
put in the URL: the app's SDK asks the embed for it once the tab loads, so the
|
||||
embed must still be open then. If the iframe is removed first, the app falls back
|
||||
to its normal sign-in. Apps that need the desktop (built-in and godmode apps,
|
||||
private apps the user can't open, or a failed token request) open `/app/<name>`
|
||||
on the GUI origin, cut off from the embed. Website shortcuts open their external
|
||||
destination with no opener.
|
||||
Apps opened from the embed carry `utm_source=puter.com&utm_medium=referral&utm_campaign=app_browser_embed`;
|
||||
the desktop and dashboard send `utm_campaign=desktop` and `utm_campaign=dashboard`.
|
||||
|
||||
## Session and framing
|
||||
|
||||
The embed reads the existing GUI session from local storage on its own origin.
|
||||
If that storage holds no session, the embed gets the session from the GUI origin's session cookie through
|
||||
`/get-gui-token`. This matters in Safari, which gives a framed origin separate
|
||||
local storage under each top-level origin, so even a sibling subdomain's iframe
|
||||
can't see the GUI's stored token. The session cookie is still sent, because the
|
||||
host is on the same site. The embed doesn't write that token back to storage. It
|
||||
does not create accounts, show a login dialog, or accept a token from its parent.
|
||||
Being signed in on a subdomain alone does not create a session on the main GUI
|
||||
origin.
|
||||
|
||||
Without a session, the embed shows the recommended apps (`RecommendedAppsService`)
|
||||
that the server put in the page, and nothing else: no installed apps, folders, or
|
||||
saved order, and no puter.js. Selecting one opens it at the same URL a signed-in
|
||||
launch uses, with no opener and no token on offer, so the app signs in on its own.
|
||||
Apps that need the desktop open `/app/<name>`. If the page carries no recommended
|
||||
apps, or a load fails, the embed displays an unavailable/error state.
|
||||
|
||||
The route's `Content-Security-Policy: frame-ancestors` allows the GUI origin and
|
||||
the embedding page's origin when it is exactly one label under `config.domain`
|
||||
with the scheme and port from `config.origin`. Subdomains the server answers
|
||||
itself (`api`, `dav`, `js`, `www`, the native-app hosts, and the host of
|
||||
`api_base_url`) and the user-content hosting domains (`static_hosting_domain`,
|
||||
`private_app_hosting_domain`, and their `_alt` variants) are refused. For
|
||||
example, `https://puter.com` permits `https://spreadsheet.puter.com` but not
|
||||
`https://api.puter.com`, and `http://puter.localhost:4100` permits
|
||||
`http://spreadsheet.puter.localhost:4100` but not
|
||||
`http://someone.site.puter.localhost:4100`. The embedding page is read from the
|
||||
iframe request's referrer, so the host must not suppress it (the browser default
|
||||
`strict-origin-when-cross-origin` is enough). All ancestors in a nested iframe
|
||||
chain must be allowed. Other GUI routes retain `X-Frame-Options: SAMEORIGIN`.
|
||||
|
||||
The embed accepts no query parameters. Requests with a query string redirect to
|
||||
the same embed path so SDK bootstrap parameters cannot override the session or API
|
||||
origin. The host needs to allow the GUI origin in its own `frame-src` CSP if it
|
||||
sets one. If sandboxing the iframe, allow scripts, same-origin storage, popups,
|
||||
and popups to escape the sandbox.
|
||||
|
||||
## Host integration
|
||||
|
||||
The iframe can be shown whether or not the user is signed in. Create it when
|
||||
“More apps” is opened, and remove it on close so reopening loads current apps
|
||||
and preferences. The host owns the modal and close control;
|
||||
there is no parent messaging API and no app list or credential is sent to it.
|
||||
The app browser fills its iframe and responds to its size.
|
||||
|
||||
This example assumes `puterOrigin` comes from the host app's deployment
|
||||
settings. Translate the button and frame labels using the host app's
|
||||
localization system.
|
||||
|
||||
```html
|
||||
<button id="more-apps" type="button">More apps</button>
|
||||
<dialog id="apps-dialog" aria-label="More apps">
|
||||
<button id="close-apps" type="button">Close</button>
|
||||
<div id="apps-frame"></div>
|
||||
</dialog>
|
||||
<style>
|
||||
#apps-dialog { padding: 12px; width: min(900px, 90vw); }
|
||||
#apps-frame iframe { width: 100%; height: min(650px, 75vh); border: 0; }
|
||||
</style>
|
||||
<script type="module">
|
||||
const puterOrigin = 'https://puter.com'; // Use this deployment's configured origin.
|
||||
const dialog = document.querySelector('#apps-dialog');
|
||||
const container = document.querySelector('#apps-frame');
|
||||
const trigger = document.querySelector('#more-apps');
|
||||
|
||||
trigger.addEventListener('click', () => {
|
||||
const frame = document.createElement('iframe');
|
||||
frame.title = 'Your apps';
|
||||
frame.src = new URL('/embed/apps', puterOrigin).href;
|
||||
container.replaceChildren(frame);
|
||||
dialog.showModal();
|
||||
});
|
||||
document.querySelector('#close-apps').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('close', () => {
|
||||
container.replaceChildren();
|
||||
trigger.focus();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Implementation and checks
|
||||
|
||||
`createAppBrowser` in `src/gui/src/UI/Dashboard/AppBrowser.js` owns the shared UI
|
||||
and data behavior; its `guestApps` option is the signed-out list. `TabApps.js`
|
||||
injects desktop actions. `appsEmbed.js` supplies new-tab launching through
|
||||
`launchAppInTab` (signed in) and `guestAppLaunchUrl` (signed out) in
|
||||
`src/gui/src/helpers/appTabLaunch.js`, which build the same app URL as
|
||||
`launch_app`, and loads only the embed entry, its SDK, and shared styles.
|
||||
The normal GUI webpack build emits `bundle.min.js` and `apps-embed.min.js`.
|
||||
The `/embed/apps` route puts the recommended list in the page's config as
|
||||
`guestApps`. No puter.js API or app-list endpoint changes are needed.
|
||||
|
||||
Run the GUI component/browser tests (with the SDK bundle built and Playwright
|
||||
Chromium installed), and the backend route tests:
|
||||
|
||||
```sh
|
||||
npx vitest run --config src/gui/vitest.config.js src/gui/src/UI/Dashboard/AppBrowser
|
||||
npx vitest run --config src/backend/vitest.config.ts src/backend/server.test.ts
|
||||
```
|
||||
@@ -63,6 +63,9 @@ store:
|
||||
sealed cursor: the last `id` it returned, and the last `(replaced_at, id)`
|
||||
among rows up to that id. Each is its own indexed read, merged into one
|
||||
page, so a row replaced after a client read it comes back from that cursor.
|
||||
`from=end` with no cursor answers an empty page whose cursor holds the
|
||||
slice's newest `id` and the slice's latest `(replaced_at, id)` as the
|
||||
replacement mark.
|
||||
|
||||
Cursors that carry a sort also pin it: a request that passes a cursor plus a
|
||||
conflicting sort is rejected with 400.
|
||||
|
||||
+7
-6
@@ -1,6 +1,6 @@
|
||||
# Browser push client
|
||||
|
||||
Puter's desktop shows pushes through `/sw.js`. Core registers it, and shows Settings → Notifications, only when the `pushEnabled` gui param is `true`; core never sets it. A backend extension that delivers pushes sets it with `services.homepage.setGuiParam('pushEnabled', true)` and can load its own code into the worker with `services.homepage.registerServiceWorkerScript(path)`.
|
||||
Puter's desktop shows pushes through `/sw.js`. Core shows Settings → Notifications only when the `pushEnabled` gui param is `true`; core never sets it. The worker is registered only when push is turned on in the browser, and a load removes one left without a subscription. A backend extension that delivers pushes sets it with `services.homepage.setGuiParam('pushEnabled', true)` and can load its own code into the worker with `services.homepage.registerServiceWorkerScript(path)`.
|
||||
|
||||
Core subscribes the browser to a push service only when the server also publishes `pushWebPushKey` (see [Built-in Web Push client](#built-in-web-push-client)), and it has no `/push/*` routes of its own. The device, app, preference and subscription endpoints below come from that extension. On a deployment without one, such as most self-hosted servers, they answer core's plain 404 (`code: 'not_found'`), which `puter.notifications` reports as `push_unavailable` and the Notifications tab shows as unavailable. A device the extension can't find is `device_not_found`.
|
||||
|
||||
@@ -22,12 +22,13 @@ When the browser replaces the subscription (`pushsubscriptionchange`), `sw.js` p
|
||||
|
||||
Before a notification is pushed, the server asks the open clients whether they received it live. It sends `notif.receipt` with `{ uid }` on the user's sockets for the app that sent it, or on the desktop's sockets for Puter's own notifications. The SDK remembers the uids its page received through `onNotification()`, `onPush()` or the desktop's feed, and answers once its page has that notification, or with `{ received: false }` when it never gets it. The server waits up to 5 seconds (`notificationAckTimeoutMs`).
|
||||
|
||||
An answer carries `visibilityState`, `focused` and `showsOwn`, the last being whether the page shows the notification itself. A website page shows it itself when it is hidden or unfocused, has an `onPush()` handler and notification permission, and no other tab of the same site is focused. Inside the desktop, the desktop shows it, and an app's `onPush()` result is never shown as a browser notification.
|
||||
An answer carries `visibilityState` and `focused` as they were when the page received the notification. Pages never show a browser notification themselves; pushes do.
|
||||
|
||||
- An answer from a page that is visible and focused, or that shows the notification itself, stops the push.
|
||||
- Otherwise the notification is pushed. A website's pushes go only to the browsers that subscribed through its own worker, never to Puter's `/sw.js`. An app inside the desktop pushes through `/sw.js`, which shows the app's name and icon.
|
||||
- A hidden desktop tab shows no banner, so answers only from hidden desktop tabs don't stop a push of Puter's own notifications.
|
||||
- A client that can't answer, such as an older puter.js, holds the push back while it is connected.
|
||||
- An answer from a page that was visible and focused stops the push (`handled` on `notif.delivery.settled`). An older client that doesn't send `focused` counts while visible.
|
||||
- Otherwise the notification is pushed, to every browser the user subscribed. A website's pushes go only to the browsers that subscribed through its own worker, never to Puter's `/sw.js`. An app inside the desktop pushes through `/sw.js`, which shows the app's name and icon.
|
||||
- A client that can't answer, such as an older puter.js, doesn't hold the push back.
|
||||
|
||||
The ask reaches only the sockets in the asking region. So that other regions hear about a page too, a page that receives a notification live while visible and focused also sends `notif.handled` with `{ uid }` on the socket it came on. Core relays it as the node-local `notif.handled` event with the socket's user and app, at most 60 a minute per user; an extension that delivers pushes decides what to do with it.
|
||||
|
||||
## Registering
|
||||
|
||||
|
||||
@@ -285,6 +285,18 @@ describe('AbstractDatabaseClient — SQL generation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the database clock as epoch milliseconds in each dialect', () => {
|
||||
expect(sqlite().nowMsExpr()).toBe(
|
||||
"CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)",
|
||||
);
|
||||
expect(mysql().nowMsExpr()).toBe(
|
||||
"(TIMESTAMPDIFF(MICROSECOND, '1970-01-01 00:00:00', UTC_TIMESTAMP(6)) DIV 1000)",
|
||||
);
|
||||
expect(postgres().nowMsExpr()).toBe(
|
||||
'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)',
|
||||
);
|
||||
});
|
||||
|
||||
it('describes a stored timestamp the way each driver reads it back', () => {
|
||||
const seconds = Date.UTC(2026, 9, 11, 8, 5, 9) / 1000;
|
||||
expect(sqlite().timestampValue(seconds)).toBe('2026-10-11 08:05:09');
|
||||
@@ -306,3 +318,13 @@ describe('AbstractDatabaseClient — SQL generation', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('builds an insert for batchWrite and shares it with insert', async () => {
|
||||
const client = new FixtureDatabaseClient([]);
|
||||
const entry = client.insertEntry('widget', { name: "reader's", enabled: true });
|
||||
expect(entry.values).toEqual(["reader's", true]);
|
||||
expect(entry.statement).toContain('VALUES (?, ?)');
|
||||
await client.insert('widget', { name: "reader's", enabled: true });
|
||||
expect(client.writes).toEqual([{ query: entry.statement, params: entry.values }]);
|
||||
});
|
||||
@@ -110,14 +110,23 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
tableName: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<WriteResult> {
|
||||
const entry = this.insertEntry(tableName, data);
|
||||
return this.write(
|
||||
entry.statement + this.returningIdClause(),
|
||||
entry.values,
|
||||
);
|
||||
}
|
||||
|
||||
/** An insert that can join other writes in a transaction. */
|
||||
insertEntry(tableName: string, data: Record<string, unknown>): BatchEntry {
|
||||
const cols = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const sql =
|
||||
`INSERT INTO ${this.quoteIdentifier(tableName)} ` +
|
||||
`(${cols.map((c) => this.quoteIdentifier(c)).join(', ')}) ` +
|
||||
`VALUES (${cols.map(() => '?').join(', ')})` +
|
||||
this.returningIdClause();
|
||||
return this.write(sql, values);
|
||||
return {
|
||||
statement:
|
||||
`INSERT INTO ${this.quoteIdentifier(tableName)} ` +
|
||||
`(${cols.map((c) => this.quoteIdentifier(c)).join(', ')}) ` +
|
||||
`VALUES (${cols.map(() => '?').join(', ')})`,
|
||||
values: Object.values(data),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether `read()` goes to a separate replica rather than the primary. */
|
||||
@@ -401,6 +410,21 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The database's own clock as epoch milliseconds, so nodes whose clocks
|
||||
* disagree still agree on it. The mysql form ignores the session time zone,
|
||||
* so a DST change can't shift it.
|
||||
*/
|
||||
nowMsExpr(): string {
|
||||
return this.case({
|
||||
sqlite: "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)",
|
||||
postgres:
|
||||
'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)',
|
||||
otherwise:
|
||||
"(TIMESTAMPDIFF(MICROSECOND, '1970-01-01 00:00:00', UTC_TIMESTAMP(6)) DIV 1000)",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What a read of a timestamp column holding `seconds` returns: sqlite gives
|
||||
* back the UTC text it stored, mysql2 and pg a `Date` read in node's time
|
||||
|
||||
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
/** Highest schema version the migration table can reach. */
|
||||
const CURRENT_SCHEMA_VERSION = 95;
|
||||
const CURRENT_SCHEMA_VERSION = 98;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
@@ -724,6 +724,85 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps notification.send_key unique', async () => {
|
||||
const rows = await client.read('PRAGMA index_list(`notification`)');
|
||||
expect(
|
||||
rows.find((r) => r.name === 'idx_notification_send_key'),
|
||||
).toMatchObject({ unique: 1 });
|
||||
const columns = await client.read(
|
||||
'PRAGMA index_info(`idx_notification_send_key`)',
|
||||
);
|
||||
expect(columns.map((c) => String(c.name))).toEqual(['send_key']);
|
||||
});
|
||||
|
||||
it('creates event_user_handlers with its unique and holder indexes', async () => {
|
||||
const columns = await client.read(
|
||||
'PRAGMA table_info(`event_user_handlers`)',
|
||||
);
|
||||
expect(columns.map((c) => String(c.name))).toEqual([
|
||||
'id',
|
||||
'app_uid',
|
||||
'holder_user_id',
|
||||
'name',
|
||||
'source',
|
||||
'source_hash',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'scope_since',
|
||||
]);
|
||||
const indexes = await client.read(
|
||||
'PRAGMA index_list(`event_user_handlers`)',
|
||||
);
|
||||
expect(
|
||||
indexes.find(
|
||||
(i) => i.name === 'idx_event_user_handlers_app_holder_name',
|
||||
),
|
||||
).toMatchObject({ unique: 1 });
|
||||
expect(
|
||||
indexes.find((i) => i.name === 'idx_event_user_handlers_holder_app'),
|
||||
).toMatchObject({ unique: 0 });
|
||||
const columnsOf = async (index: string) =>
|
||||
(await client.read(`PRAGMA index_info(\`${index}\`)`)).map((c) =>
|
||||
String(c.name),
|
||||
);
|
||||
expect(await columnsOf('idx_event_user_handlers_app_holder_name')).toEqual(
|
||||
['app_uid', 'holder_user_id', 'name'],
|
||||
);
|
||||
expect(await columnsOf('idx_event_user_handlers_holder_app')).toEqual([
|
||||
'holder_user_id',
|
||||
'app_uid',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves event_handlers as it was', async () => {
|
||||
const columns = await client.read('PRAGMA table_info(`event_handlers`)');
|
||||
expect(columns.map((c) => String(c.name))).toEqual([
|
||||
'id',
|
||||
'app_uid',
|
||||
'name',
|
||||
'source',
|
||||
'source_hash',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds event_subscriptions.handler_scope, app scope for existing rows', async () => {
|
||||
const columns = await client.read(
|
||||
'PRAGMA table_info(`event_subscriptions`)',
|
||||
);
|
||||
expect(columns.find((c) => c.name === 'handler_scope')).toMatchObject({
|
||||
notnull: 1,
|
||||
dflt_value: '0',
|
||||
});
|
||||
const indexes = await client.read(
|
||||
'PRAGMA index_list(`event_subscriptions`)',
|
||||
);
|
||||
expect(indexes.map((i) => String(i.name))).toContain(
|
||||
'idx_event_subscriptions_holder',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves an already-migrated database untouched on a second boot', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-'));
|
||||
const path = join(dir, 'nested', 'puter.sqlite');
|
||||
|
||||
@@ -134,6 +134,9 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[92, ['0097_user-to-user-holder-permission-index.sql']],
|
||||
[93, ['0098_notification-groups.sql']],
|
||||
[94, ['0099_kv-share-handle-notify.sql']],
|
||||
[95, ['0100_notification-send-key.sql']],
|
||||
[96, ['0101_user-scoped-handlers.sql']],
|
||||
[97, ['0102_notification-scope-id-index.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- `notification`.`send_key`. See sqlite/0100_notification-send-key.sql for the
|
||||
-- rationale. The key is a uuid, so ascii and compared byte for byte. No
|
||||
-- per-file applied-state tracking, so every step is guarded and a replay does
|
||||
-- nothing.
|
||||
|
||||
CALL _puter_add_col('notification', 'send_key', '`send_key` char(36) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_send_key_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_notification_send_key_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'notification'
|
||||
AND INDEX_NAME = 'idx_notification_send_key'
|
||||
) THEN
|
||||
ALTER TABLE `notification` ADD UNIQUE KEY `idx_notification_send_key`
|
||||
(`send_key`), ALGORITHM=INPLACE, LOCK=NONE;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_notification_send_key_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_send_key_index;
|
||||
@@ -0,0 +1,66 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Handlers an app's user published into their own scope, kept apart from
|
||||
-- `event_handlers` so code that predates them never reads them. See
|
||||
-- sqlite/0101_user-scoped-handlers.sql for the column rationale.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline,
|
||||
-- and a guarded column add. There is no per-file applied-state tracking, so a
|
||||
-- replay has to be a no-op.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `event_user_handlers` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
-- Matches `apps`.`uid` exactly, charset included, so an equality against
|
||||
-- one never falls back to a conversion.
|
||||
`app_uid` char(40) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
|
||||
`holder_user_id` int unsigned NOT NULL,
|
||||
`name` varchar(128) NOT NULL,
|
||||
`source` mediumtext NOT NULL,
|
||||
`source_hash` char(64) NOT NULL,
|
||||
`created_at` bigint NOT NULL,
|
||||
`updated_at` bigint NOT NULL,
|
||||
-- When the scope first held a handler; kept across handler churn, so the
|
||||
-- rent sweep sees the scope's age, not its oldest remaining handler's.
|
||||
`scope_since` bigint NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_event_user_handlers_app_holder_name` (`app_uid`, `holder_user_id`, `name`),
|
||||
KEY `idx_event_user_handlers_holder_app` (`holder_user_id`, `app_uid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Guarded here rather than through `_puter_add_col`, so the file also applies
|
||||
-- on its own to a database that never ran the earlier migrations.
|
||||
DROP PROCEDURE IF EXISTS _puter_add_event_subscriptions_handler_scope;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_event_subscriptions_handler_scope()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'event_subscriptions'
|
||||
AND COLUMN_NAME = 'handler_scope'
|
||||
) THEN
|
||||
ALTER TABLE `event_subscriptions`
|
||||
ADD COLUMN `handler_scope` tinyint unsigned NOT NULL DEFAULT 0,
|
||||
ALGORITHM=INSTANT;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_event_subscriptions_handler_scope();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_event_subscriptions_handler_scope;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- `notification` (`user_id`, `audience`, `app_uid`, `id`). See
|
||||
-- sqlite/0102_notification-scope-id-index.sql for the rationale. No per-file
|
||||
-- applied-state tracking, so the index is added only when missing and a
|
||||
-- replay does nothing.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_scope_id_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_notification_scope_id_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'notification'
|
||||
AND INDEX_NAME = 'idx_notification_scope_id'
|
||||
) THEN
|
||||
ALTER TABLE `notification` ADD INDEX `idx_notification_scope_id`
|
||||
(`user_id`, `audience`, `app_uid`, `id`), ALGORITHM=INPLACE, LOCK=NONE;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_notification_scope_id_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_scope_id_index;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- notification.send_key. See sqlite/0100_notification-send-key.sql for the
|
||||
-- rationale. Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS send_key char(36) DEFAULT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_send_key
|
||||
ON notification (send_key);
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Handlers an app's user published into their own scope. See
|
||||
-- sqlite/0101_user-scoped-handlers.sql for the column rationale.
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_user_handlers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
app_uid VARCHAR(40) NOT NULL,
|
||||
holder_user_id INTEGER NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
source_hash VARCHAR(64) NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
scope_since BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_user_handlers_app_holder_name
|
||||
ON event_user_handlers (app_uid, holder_user_id, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_user_handlers_holder_app
|
||||
ON event_user_handlers (holder_user_id, app_uid);
|
||||
|
||||
ALTER TABLE event_subscriptions ADD COLUMN IF NOT EXISTS handler_scope smallint NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- notification (user_id, audience, app_uid, id). See
|
||||
-- sqlite/0102_notification-scope-id-index.sql for the rationale. Idempotent via
|
||||
-- IF NOT EXISTS.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_scope_id
|
||||
ON notification (user_id, audience, app_uid, id);
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- `notification`.`send_key`: the key of the delayed send (a `deferred`
|
||||
-- schedule) that wrote this row or last replaced it, derived from the
|
||||
-- schedule and its slot. Unique, so one slot can never write two rows, and a
|
||||
-- send that crashed after writing finds its row again by it. Null on every row
|
||||
-- a delayed send never touched.
|
||||
|
||||
ALTER TABLE `notification` ADD COLUMN `send_key` TEXT DEFAULT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_notification_send_key`
|
||||
ON `notification` (`send_key`);
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Handlers an app's user published into their own scope, in a table of their
|
||||
-- own so `event_handlers` stays the app owner's set. Same columns as
|
||||
-- `event_handlers` (see 0076_event-handlers.sql) plus the holder.
|
||||
--
|
||||
-- - `holder_user_id` : the user whose scope this is. A name is unique per
|
||||
-- (app, holder), so the same name can live in the app's
|
||||
-- set and in each holder's.
|
||||
-- - `(holder_user_id, app_uid)` : a holder's scopes, for listing and for the
|
||||
-- rent count.
|
||||
--
|
||||
-- `event_subscriptions.handler_scope` says which table a row's `handler_name`
|
||||
-- refers to: 0 the app's set, 1 the row holder's scope.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `event_user_handlers` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"app_uid" TEXT NOT NULL,
|
||||
"holder_user_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"source_hash" TEXT NOT NULL,
|
||||
"created_at" INTEGER NOT NULL, -- unix seconds
|
||||
"updated_at" INTEGER NOT NULL,
|
||||
"scope_since" INTEGER NOT NULL -- unix seconds; the scope's first handler, kept across churn
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_event_user_handlers_app_holder_name`
|
||||
ON `event_user_handlers` (`app_uid`, `holder_user_id`, `name`);
|
||||
CREATE INDEX IF NOT EXISTS `idx_event_user_handlers_holder_app`
|
||||
ON `event_user_handlers` (`holder_user_id`, `app_uid`);
|
||||
|
||||
ALTER TABLE `event_subscriptions` ADD COLUMN `handler_scope` INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Index a mailbox slice by row id, so a fetch finds the newest row of a slice,
|
||||
-- and pages forward through one, with a seek rather than a scan of the slice.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_notification_scope_id`
|
||||
ON `notification` (`user_id`, `audience`, `app_uid`, `id`);
|
||||
@@ -117,7 +117,7 @@ export type EventMap = {
|
||||
model_used: string;
|
||||
service_used: string;
|
||||
intended_service: string;
|
||||
model_details: {
|
||||
model_details?: {
|
||||
id: string;
|
||||
provider: string;
|
||||
input_cost_key: string;
|
||||
@@ -200,6 +200,16 @@ export type EventMap = {
|
||||
};
|
||||
|
||||
// ---- Auth / signup ----
|
||||
/**
|
||||
* Which signup methods are open, emitted via `emitAndWait` before every
|
||||
* signup and whenever the GUI reads the provider list. A listener sets
|
||||
* `oidcOnly` to refuse form and temp signups, leaving OIDC and team
|
||||
* provisioning open; `message` is what a refused form signup is told.
|
||||
*/
|
||||
'puter.signup.policy': {
|
||||
oidcOnly: boolean;
|
||||
message: string | null;
|
||||
};
|
||||
'puter.signup.validate': {
|
||||
allow: boolean;
|
||||
email?: string;
|
||||
@@ -546,6 +556,14 @@ export type EventMap = {
|
||||
'kv.share.retired': { handles: string[]; userId: number };
|
||||
|
||||
// ---- Notifications ----
|
||||
/** An app message was acknowledged after its state committed. */
|
||||
'notif.acknowledged': {
|
||||
userId: number;
|
||||
uid: string;
|
||||
type: string;
|
||||
audience: string;
|
||||
appUid: string;
|
||||
};
|
||||
/**
|
||||
* A notification row exists. Emitted after the insert commits — nothing
|
||||
* pushes a uid before there is a row to name — and carries the scope tuple
|
||||
@@ -579,8 +597,12 @@ export type EventMap = {
|
||||
value: Record<string, unknown>;
|
||||
/** An earlier notification rewritten in place. */
|
||||
updated?: true;
|
||||
/** Replaced an earlier notification in its group. */
|
||||
replaced?: true;
|
||||
/** Names this one send, for a receipt asked about it. */
|
||||
sendId: string;
|
||||
/** The same `sendId` may be emitted again, as a delayed send is. */
|
||||
replayable?: true;
|
||||
};
|
||||
/**
|
||||
* One notification, addressed at whatever sockets each region holds for the
|
||||
@@ -614,6 +636,18 @@ export type EventMap = {
|
||||
/** A peer region holds a socket for the user. */
|
||||
peerRegions: boolean;
|
||||
responders: NotifDeliveryResponder[];
|
||||
/** A page here got it live while visible and focused. */
|
||||
handled: boolean;
|
||||
};
|
||||
/**
|
||||
* A page reported that it got a notification live while visible and
|
||||
* focused. Node-local; `appUid` is the reporting socket's app, absent for
|
||||
* the desktop.
|
||||
*/
|
||||
'notif.handled': {
|
||||
userId: number;
|
||||
appUid?: string;
|
||||
uid: string;
|
||||
};
|
||||
|
||||
// ---- Metering ----
|
||||
@@ -664,9 +698,18 @@ export type EventMap = {
|
||||
// and going away (1→0, whether by removing the last handler or by the
|
||||
// destroy route). `actor.user` is the app's owner, not the caller — a
|
||||
// developer session publishing for an app it owns is the common case, but
|
||||
// billing follows ownership.
|
||||
'events.worker.create': { actor: Actor; appUid: string };
|
||||
'events.worker.destroy': { actor: Actor; appUid: string };
|
||||
// billing follows ownership. For `scope: 'user'` the unit is one user's
|
||||
// handlers in one app instead, and `actor.user` is that user.
|
||||
'events.worker.create': {
|
||||
actor: Actor;
|
||||
appUid: string;
|
||||
scope: 'app' | 'user';
|
||||
};
|
||||
'events.worker.destroy': {
|
||||
actor: Actor;
|
||||
appUid: string;
|
||||
scope: 'app' | 'user';
|
||||
};
|
||||
|
||||
// ---- Outer / GUI broadcast ----
|
||||
'outer.cacheUpdate': {
|
||||
@@ -830,6 +873,12 @@ export type EventMap = {
|
||||
/** One access token was revoked; only its own connections should drop. */
|
||||
'auth.access-token.revoked': { token_uid: string };
|
||||
|
||||
/** Same-cluster eviction for raw realtime WebSockets. */
|
||||
'pubsub.realtime.auth-revoked': {
|
||||
sessionUids?: string[];
|
||||
tokenUid?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A grant was withdrawn, so whatever was standing on it has to be settled
|
||||
* rather than left to fail its next check.
|
||||
@@ -1055,11 +1104,6 @@ export interface NotifDeliveryResponder {
|
||||
visibilityState: 'visible' | 'hidden' | 'unknown';
|
||||
/** The page's window has focus. Absent from older clients. */
|
||||
focused?: boolean;
|
||||
/**
|
||||
* The page shows its own browser notification while hidden or unfocused.
|
||||
* Absent from older clients.
|
||||
*/
|
||||
showsOwn?: boolean;
|
||||
}
|
||||
|
||||
export type EventMetadata = {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -35,6 +35,7 @@ import { PuterClient } from '../types.js';
|
||||
* x-puter-internal-auth: <internal secret>
|
||||
* x-puter-events-script: <script the handler set deploys as>
|
||||
* x-puter-events-app: <app uid, for the deploy-on-miss callback>
|
||||
* x-puter-events-holder: <holder uuid of a user scope; absent for app scope>
|
||||
* x-puter-events-key: <key derived for that script>
|
||||
* { handler, event, ctx, token }
|
||||
*
|
||||
@@ -43,6 +44,20 @@ import { PuterClient } from '../types.js';
|
||||
*
|
||||
* On a miss, a handler set here may deploy the script itself and retry once
|
||||
* with `x-puter-events-deployed: 1`, skipping the dispatcher's own callback.
|
||||
*
|
||||
* Calls that come due within a few milliseconds of each other go out together:
|
||||
*
|
||||
* POST <events dispatcher>/invoke-batch
|
||||
* x-puter-internal-auth: <internal secret>
|
||||
* { calls: [{ script, appUid, holder?, key, deployed?, body, waitMs }] }
|
||||
*
|
||||
* The dispatcher runs a few at a time, in order, and streams NDJSON: `{ i,
|
||||
* started: true }` when a call goes to its worker, then `{ i, status, handled?,
|
||||
* error?, retryAfter?, dispatch? }` when it finishes — the same facts the
|
||||
* single route carries in its status and headers. A call's timeout runs from
|
||||
* its `started` line, so time spent queued behind other calls is never the
|
||||
* handler's. A call still queued `waitMs` after the batch arrived is never
|
||||
* started and answers `dispatch: "queue-timeout"`.
|
||||
*/
|
||||
|
||||
/** The one route an events worker answers. */
|
||||
@@ -51,6 +66,27 @@ export const EVENTS_INVOKE_PATH = '/__events/invoke';
|
||||
/** The route the events dispatcher takes an invocation on. */
|
||||
export const EVENTS_DISPATCH_PATH = '/invoke';
|
||||
|
||||
/** The route it takes several on, answering each as it finishes. */
|
||||
export const EVENTS_DISPATCH_BATCH_PATH = '/invoke-batch';
|
||||
|
||||
/** Calls one batch carries at most. */
|
||||
export const INVOKE_BATCH_SIZE = 60;
|
||||
|
||||
/** How long the first call of a batch waits for others to join it. */
|
||||
export const INVOKE_BATCH_WINDOW_MS = 5;
|
||||
|
||||
/**
|
||||
* How long a dispatcher without the batch route is sent single calls before the
|
||||
* batch route is tried again.
|
||||
*/
|
||||
const BATCH_UNSUPPORTED_RETRY_MS = 5 * 60_000;
|
||||
|
||||
/**
|
||||
* How long past its wait a batched call's `started` line may take to arrive
|
||||
* before the call is given up as never started.
|
||||
*/
|
||||
const BATCH_START_GRACE_MS = 2_000;
|
||||
|
||||
/**
|
||||
* Header the dispatcher marks its own failures with. Anything carrying it never
|
||||
* reached a handler, so its status says nothing about the delivery.
|
||||
@@ -92,6 +128,12 @@ const PLATFORM_ERROR_CODES = new Set([
|
||||
*/
|
||||
export const EVENTS_DEPLOYED_HEADER = 'x-puter-events-deployed';
|
||||
|
||||
/**
|
||||
* Names the user whose scope a script belongs to, so a miss deploys it from
|
||||
* their handlers. Absent for an app's own scope.
|
||||
*/
|
||||
export const EVENTS_HOLDER_HEADER = 'x-puter-events-holder';
|
||||
|
||||
/**
|
||||
* Dispatch-error reasons this backend can resolve itself by deploying, rather
|
||||
* than wait on the rehydrate callback finding a backend behind the dispatcher's
|
||||
@@ -112,21 +154,90 @@ const SELF_DEPLOYABLE_MISS_REASONS = new Set([
|
||||
*/
|
||||
const ALREADY_DEPLOYED_MISS_REASONS = new Set(['deploy-timeout']);
|
||||
|
||||
/** Whose call asked for the deploy, for the share of deploys it may take. */
|
||||
export interface WorkerMissOptions {
|
||||
lane?: InvokeLane;
|
||||
/**
|
||||
* Holder uuid of the user scope the script belongs to; absent for app
|
||||
* scope.
|
||||
*/
|
||||
holder?: string;
|
||||
}
|
||||
|
||||
/** What a self-deploy attempt did, and to which app/script it applies. */
|
||||
export type WorkerMissHandler = (
|
||||
appUid: string,
|
||||
script: string,
|
||||
options: WorkerMissOptions,
|
||||
) => Promise<'deployed' | string>;
|
||||
|
||||
/**
|
||||
* `work`'s result, or null once `ms` has passed. The work keeps running, and a
|
||||
* failure after the cutoff is logged since nothing else would see it.
|
||||
*/
|
||||
const withinTime = async <T>(
|
||||
work: Promise<T>,
|
||||
ms: number,
|
||||
): Promise<T | null> => {
|
||||
let timedOut = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const limit = new Promise<null>((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => {
|
||||
timedOut = true;
|
||||
resolve(null);
|
||||
},
|
||||
Math.max(0, ms),
|
||||
);
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
await work;
|
||||
} catch (err) {
|
||||
if (timedOut) console.warn('[events] deploy failed late', err);
|
||||
}
|
||||
})();
|
||||
try {
|
||||
return await Promise.race([work, limit]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
/** How long a handler has to answer before the attempt is abandoned. */
|
||||
export const EVENTS_INVOKE_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* How many invoke timeouts a delivery's worker lease lasts. The lease store
|
||||
* reads this, and `invoke()` keeps a whole call, deploy and resend included,
|
||||
* inside it.
|
||||
*/
|
||||
export const EVENTS_WORKER_LEASE_FACTOR = 2;
|
||||
|
||||
/**
|
||||
* Left unspent at the end of the lease, and the least a resend is worth
|
||||
* starting with: below it the call is given up as `unavailable` and retried.
|
||||
*/
|
||||
export const EVENTS_DEADLINE_MARGIN_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Connections held open to the dispatcher, so a busy fleet is not
|
||||
* re-handshaking. Also how many calls one transport makes at once: a call past
|
||||
* that waits for a free one before its invoke timeout starts.
|
||||
*/
|
||||
export const INVOKE_CONNECTIONS = 256;
|
||||
|
||||
/**
|
||||
* Of those, how many schedule runs may hold at once, so a burst of schedules
|
||||
* can never take the connections event deliveries need.
|
||||
*/
|
||||
export const SCHEDULE_INVOKE_CONNECTIONS = 64;
|
||||
|
||||
/** Whose call this is, for the connection share it may take. */
|
||||
export type InvokeLane = 'delivery' | 'schedule';
|
||||
|
||||
/** What a deploy answers when another deploy of the region holds the way. */
|
||||
export const DEPLOY_BUSY = 'busy';
|
||||
const INVOKE_KEEP_ALIVE_MS = 30_000;
|
||||
|
||||
/**
|
||||
@@ -141,6 +252,8 @@ export interface WorkerInvokeRequest {
|
||||
/** Script the app's current handler set deploys as. */
|
||||
script: string;
|
||||
appUid: string;
|
||||
/** Holder uuid, when the handler lives in a user scope. */
|
||||
holder?: string;
|
||||
handler: string;
|
||||
/** The subscriber's app session token. */
|
||||
token: string;
|
||||
@@ -154,6 +267,8 @@ export interface WorkerInvokeRequest {
|
||||
export interface EventsInvokeCall {
|
||||
script: string;
|
||||
appUid: string;
|
||||
/** Holder uuid of the user scope the script belongs to. */
|
||||
holder?: string;
|
||||
/** Value the script compares against its own invoke-key binding. */
|
||||
key: string;
|
||||
/** Serialized `{ handler, event, ctx, token }`. */
|
||||
@@ -161,6 +276,17 @@ export interface EventsInvokeCall {
|
||||
timeoutMs: number;
|
||||
/** Set on the retry after this backend deployed the script itself. */
|
||||
deployed?: boolean;
|
||||
lane?: InvokeLane;
|
||||
/**
|
||||
* Builds `body` again at send time, for a transport that waits for a
|
||||
* connection first, so a send time stamped into it is the real one.
|
||||
*/
|
||||
render?: () => string;
|
||||
/**
|
||||
* Rechecked by a transport that waits before sending; false drops the call
|
||||
* with {@link supersededAnswer}.
|
||||
*/
|
||||
beforeSend?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** What one attempt at delivering an invocation came back with. */
|
||||
@@ -176,6 +302,8 @@ export interface EventsInvokeAnswer {
|
||||
timedOut?: boolean;
|
||||
/** From a `retry-after` header in seconds. */
|
||||
retryAfterMs?: number;
|
||||
/** A deploy this call needed was put off: another held the way. */
|
||||
deployBusy?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,6 +326,8 @@ export type WorkerInvokeOutcome =
|
||||
|
||||
export interface WorkerInvokeResult {
|
||||
outcome: WorkerInvokeOutcome;
|
||||
/** A deploy on miss was put off because another one held the way. */
|
||||
deployBusy?: boolean;
|
||||
/** The status the handler answered with, or `null` when it never did. */
|
||||
status: number | null;
|
||||
/** Why it could not answer, when nothing did. */
|
||||
@@ -206,9 +336,24 @@ export interface WorkerInvokeResult {
|
||||
retryAfterMs?: number;
|
||||
}
|
||||
|
||||
/** How one invocation is sent. */
|
||||
export interface WorkerInvokeOptions {
|
||||
lane?: InvokeLane;
|
||||
/** Rechecked before each send, including a resend after deployment. */
|
||||
beforeSend?: () => Promise<boolean>;
|
||||
/** Applied to the event as each attempt is sent. */
|
||||
stampEvent?: (event: unknown) => unknown;
|
||||
}
|
||||
|
||||
const NO_TRANSPORT =
|
||||
'no events dispatcher configured (url and internal secret)';
|
||||
|
||||
/** What a call whose `beforeSend` said no answers instead of being sent. */
|
||||
const supersededAnswer = (): EventsInvokeAnswer => ({
|
||||
status: null,
|
||||
error: 'invocation superseded',
|
||||
});
|
||||
|
||||
export class EventsWorkerInvokerClient extends PuterClient {
|
||||
/** Set only where invocations do not leave the process — local development. */
|
||||
#transport: EventsInvokeTransport | null = null;
|
||||
@@ -236,7 +381,10 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
this.#dispatcher = null;
|
||||
}
|
||||
|
||||
async invoke(request: WorkerInvokeRequest): Promise<WorkerInvokeResult> {
|
||||
async invoke(
|
||||
request: WorkerInvokeRequest,
|
||||
options: WorkerInvokeOptions = {},
|
||||
): Promise<WorkerInvokeResult> {
|
||||
const transport = this.#pickTransport();
|
||||
if (!transport)
|
||||
return {
|
||||
@@ -245,32 +393,86 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
error: NO_TRANSPORT,
|
||||
};
|
||||
|
||||
const call: EventsInvokeCall = {
|
||||
script: request.script,
|
||||
appUid: request.appUid,
|
||||
key: request.key,
|
||||
body: JSON.stringify({
|
||||
const { stampEvent } = options;
|
||||
const render = (): string =>
|
||||
JSON.stringify({
|
||||
handler: request.handler,
|
||||
event: request.event,
|
||||
event: stampEvent ? stampEvent(request.event) : request.event,
|
||||
ctx: request.ctx,
|
||||
token: request.token,
|
||||
}),
|
||||
timeoutMs: this.#timeoutMs(),
|
||||
});
|
||||
const timeoutMs = this.#timeoutMs();
|
||||
// The whole call, deploy and resend included, ends before the lease a
|
||||
// delivery holds would let it be claimed again mid-run.
|
||||
// A short configured timeout gets a proportionally short margin.
|
||||
const margin = Math.min(EVENTS_DEADLINE_MARGIN_MS, timeoutMs / 2);
|
||||
const deadline =
|
||||
Date.now() + timeoutMs * EVENTS_WORKER_LEASE_FACTOR - margin;
|
||||
const send = async (
|
||||
deployed: boolean,
|
||||
sendTimeoutMs = timeoutMs,
|
||||
): Promise<EventsInvokeAnswer> => {
|
||||
if (options.beforeSend && !(await options.beforeSend()))
|
||||
return supersededAnswer();
|
||||
return transport.send({
|
||||
script: request.script,
|
||||
appUid: request.appUid,
|
||||
...(request.holder ? { holder: request.holder } : {}),
|
||||
key: request.key,
|
||||
body: render(),
|
||||
timeoutMs: sendTimeoutMs,
|
||||
...(deployed ? { deployed: true } : {}),
|
||||
...(options.lane ? { lane: options.lane } : {}),
|
||||
...(stampEvent ? { render } : {}),
|
||||
...(options.beforeSend
|
||||
? { beforeSend: options.beforeSend }
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
let result = await transport.send(call);
|
||||
if (this.#missHandler && selfDeployable(result)) {
|
||||
const outcome = await this.#missHandler(
|
||||
request.appUid,
|
||||
request.script,
|
||||
// A send may wait for a connection for half its timeout before the
|
||||
// run's own timeout starts, so it is given what is left divided by that.
|
||||
const resend = (): Promise<EventsInvokeAnswer> | null => {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining < margin) return null;
|
||||
return send(
|
||||
true,
|
||||
Math.floor(
|
||||
Math.min(
|
||||
timeoutMs,
|
||||
remaining / (1 + CONNECTION_WAIT_SHARE),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
const outOfTime = (): WorkerInvokeResult => ({
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: 'out of time to resend after deploy',
|
||||
});
|
||||
|
||||
let result = await send(false);
|
||||
if (this.#missHandler && selfDeployable(result)) {
|
||||
// The deploy may be shared with other callers, so it is left
|
||||
// running when this call runs out of time for it.
|
||||
const outcome = await withinTime(
|
||||
this.#missHandler(request.appUid, request.script, {
|
||||
lane: options.lane,
|
||||
...(request.holder ? { holder: request.holder } : {}),
|
||||
}),
|
||||
deadline - Date.now() - margin,
|
||||
);
|
||||
if (outcome === null) return outOfTime();
|
||||
if (outcome !== 'deployed')
|
||||
return {
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: `deploy: ${outcome}`,
|
||||
...(outcome === DEPLOY_BUSY ? { deployBusy: true } : {}),
|
||||
};
|
||||
result = await transport.send({ ...call, deployed: true });
|
||||
const resent = await resend();
|
||||
if (!resent) return outOfTime();
|
||||
result = resent;
|
||||
} else if (
|
||||
result.status === null &&
|
||||
result.dispatchReason &&
|
||||
@@ -278,10 +480,19 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
) {
|
||||
// No deploy to trigger — just give propagation the one retry
|
||||
// it usually needs, without spending an upload on it.
|
||||
result = await transport.send({ ...call, deployed: true });
|
||||
const resent = await resend();
|
||||
if (!resent) return outOfTime();
|
||||
result = resent;
|
||||
}
|
||||
|
||||
const { status, handled, error, timedOut, retryAfterMs } = result;
|
||||
if (result.deployBusy)
|
||||
return {
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error,
|
||||
deployBusy: true,
|
||||
};
|
||||
// Past the timeout the handler was most likely still running, which
|
||||
// is its own doing; any other missing answer never reached it.
|
||||
if (status === null)
|
||||
@@ -313,9 +524,19 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
// a misconfiguration worth reporting once per attempt rather than a
|
||||
// round trip.
|
||||
if (!dispatcherUrl || !internalSecret) return null;
|
||||
const { invokeBatchSize, invokeBatchWindowMs } =
|
||||
this.config.events ?? {};
|
||||
this.#dispatcher ??= new DispatcherInvokeTransport(
|
||||
dispatcherUrl,
|
||||
internalSecret,
|
||||
{
|
||||
...(invokeBatchSize !== undefined
|
||||
? { batchSize: invokeBatchSize }
|
||||
: {}),
|
||||
...(invokeBatchWindowMs !== undefined
|
||||
? { batchWindowMs: invokeBatchWindowMs }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
return this.#dispatcher;
|
||||
}
|
||||
@@ -328,28 +549,65 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** A call waiting in, or sent with, the current batch. */
|
||||
interface BatchedCall {
|
||||
call: EventsInvokeCall;
|
||||
resolve: (answer: EventsInvokeAnswer) => void;
|
||||
/** For a `beforeSend` that threw. */
|
||||
reject: (err: unknown) => void;
|
||||
/** When its wait for a connection began, for what is left of it. */
|
||||
waitingSince: number;
|
||||
}
|
||||
|
||||
export interface DispatcherInvokeTransportOptions {
|
||||
fetchImpl?: typeof undiciFetch;
|
||||
/** Calls per batch; 1 sends every call on its own. */
|
||||
batchSize?: number;
|
||||
batchWindowMs?: number;
|
||||
}
|
||||
|
||||
/** Reaches an events worker the only way production can: through the dispatcher. */
|
||||
export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
readonly #url: string;
|
||||
readonly #secret: string;
|
||||
readonly #fetchImpl: typeof undiciFetch;
|
||||
readonly #batchSize: number;
|
||||
readonly #batchWindowMs: number;
|
||||
#agent: Agent | null = null;
|
||||
/** Calls holding a connection slot. */
|
||||
#active = 0;
|
||||
/** Calls waiting for one, longest-waiting first. */
|
||||
readonly #waiting = new Set<() => void>();
|
||||
/** Of those, schedule runs. */
|
||||
#scheduleActive = 0;
|
||||
/** Calls waiting for one, longest-waiting first, with their lane. */
|
||||
readonly #waiting = new Map<() => void, InvokeLane>();
|
||||
/** Calls gathered for the next batch. */
|
||||
#batch: BatchedCall[] = [];
|
||||
#batchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Until when the dispatcher is taken not to have the batch route. */
|
||||
#batchUnsupportedUntil = 0;
|
||||
|
||||
constructor(
|
||||
dispatcherUrl: string,
|
||||
secret: string,
|
||||
options: { fetchImpl?: typeof undiciFetch } = {},
|
||||
options: DispatcherInvokeTransportOptions = {},
|
||||
) {
|
||||
this.#url = dispatcherUrl;
|
||||
// Joined as a string: `new URL(path, base)` resolves an absolute path
|
||||
// against the base's origin, dropping any path prefix it carries.
|
||||
this.#url = dispatcherUrl.replace(/\/$/, '');
|
||||
this.#secret = secret;
|
||||
this.#fetchImpl = options.fetchImpl ?? undiciFetch;
|
||||
this.#batchSize = Math.max(
|
||||
1,
|
||||
Math.floor(options.batchSize ?? INVOKE_BATCH_SIZE),
|
||||
);
|
||||
this.#batchWindowMs = Math.max(
|
||||
0,
|
||||
options.batchWindowMs ?? INVOKE_BATCH_WINDOW_MS,
|
||||
);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#flush();
|
||||
void this.#agent?.close().catch(() => {});
|
||||
this.#agent = null;
|
||||
}
|
||||
@@ -357,29 +615,102 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
async send(call: EventsInvokeCall): Promise<EventsInvokeAnswer> {
|
||||
// No `timedOut`: the handler never got this call, so the wait is not
|
||||
// its failure.
|
||||
if (!(await this.#acquire(call.timeoutMs * CONNECTION_WAIT_SHARE)))
|
||||
const lane = call.lane ?? 'delivery';
|
||||
const waitingSince = Date.now();
|
||||
if (
|
||||
!(await this.#acquire(call.timeoutMs * CONNECTION_WAIT_SHARE, lane))
|
||||
)
|
||||
return {
|
||||
status: null,
|
||||
error: 'no free connection to the events dispatcher',
|
||||
};
|
||||
try {
|
||||
// `new URL(path, base)` resolves an absolute path against the
|
||||
// base's origin, dropping any path prefix `base` already carries.
|
||||
const url = `${this.#url.replace(/\/$/, '')}${EVENTS_DISPATCH_PATH}`;
|
||||
const response = await this.#fetchImpl(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-puter-internal-auth': this.#secret,
|
||||
'x-puter-events-script': call.script,
|
||||
'x-puter-events-app': call.appUid,
|
||||
'x-puter-events-key': call.key,
|
||||
...(call.deployed ? { [EVENTS_DEPLOYED_HEADER]: '1' } : {}),
|
||||
},
|
||||
body: call.body,
|
||||
signal: AbortSignal.timeout(call.timeoutMs),
|
||||
dispatcher: this.#dispatcherFor(),
|
||||
if (this.#batchSize === 1) {
|
||||
// The wait for a connection may have outlived the call.
|
||||
if (call.beforeSend && !(await call.beforeSend()))
|
||||
return supersededAnswer();
|
||||
return await this.#sendOne(call);
|
||||
}
|
||||
return await new Promise<EventsInvokeAnswer>((resolve, reject) => {
|
||||
this.#batch.push({ call, resolve, reject, waitingSince });
|
||||
if (this.#batch.length >= this.#batchSize) this.#flush();
|
||||
else
|
||||
this.#batchTimer ??= setTimeout(
|
||||
() => this.#flush(),
|
||||
this.#batchWindowMs,
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
this.#release(lane);
|
||||
}
|
||||
}
|
||||
|
||||
/** Send what has gathered: alone when it is one call, else as a batch. */
|
||||
#flush(): void {
|
||||
if (this.#batchTimer) clearTimeout(this.#batchTimer);
|
||||
this.#batchTimer = null;
|
||||
const batch = this.#batch;
|
||||
this.#batch = [];
|
||||
if (batch.length === 0) return;
|
||||
if (batch.some(({ call }) => call.beforeSend))
|
||||
void this.#sendStillCurrent(batch);
|
||||
else this.#sendGathered(batch);
|
||||
}
|
||||
|
||||
#sendGathered(batch: BatchedCall[]): void {
|
||||
if (batch.length === 1 || Date.now() < this.#batchUnsupportedUntil) {
|
||||
for (const { call, resolve } of batch)
|
||||
void this.#sendOne(call).then(resolve);
|
||||
return;
|
||||
}
|
||||
void this.#sendBatch(batch);
|
||||
}
|
||||
|
||||
/** Drops calls superseded while they were gathered, then sends the rest. */
|
||||
async #sendStillCurrent(batch: BatchedCall[]): Promise<void> {
|
||||
const checked = await Promise.all(
|
||||
batch.map(async (entry) => {
|
||||
if (!entry.call.beforeSend) return entry;
|
||||
try {
|
||||
if (await entry.call.beforeSend()) return entry;
|
||||
entry.resolve(supersededAnswer());
|
||||
} catch (err) {
|
||||
entry.reject(err);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
const current = checked.filter(
|
||||
(entry): entry is BatchedCall => entry !== null,
|
||||
);
|
||||
if (current.length > 0) this.#sendGathered(current);
|
||||
}
|
||||
|
||||
/** One call on the single route. */
|
||||
async #sendOne(call: EventsInvokeCall): Promise<EventsInvokeAnswer> {
|
||||
try {
|
||||
const response = await this.#fetchImpl(
|
||||
`${this.#url}${EVENTS_DISPATCH_PATH}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-puter-internal-auth': this.#secret,
|
||||
'x-puter-events-script': call.script,
|
||||
'x-puter-events-app': call.appUid,
|
||||
...(call.holder
|
||||
? { [EVENTS_HOLDER_HEADER]: call.holder }
|
||||
: {}),
|
||||
'x-puter-events-key': call.key,
|
||||
...(call.deployed
|
||||
? { [EVENTS_DEPLOYED_HEADER]: '1' }
|
||||
: {}),
|
||||
},
|
||||
body: call.render ? call.render() : call.body,
|
||||
signal: AbortSignal.timeout(call.timeoutMs),
|
||||
dispatcher: this.#dispatcherFor(),
|
||||
},
|
||||
);
|
||||
// The body is not read: a handler answers with a status, and
|
||||
// whatever else it writes is its own business.
|
||||
void response.body?.cancel().catch(() => {});
|
||||
@@ -390,24 +721,198 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
EVENTS_DISPATCH_ERROR_HEADER,
|
||||
);
|
||||
if (dispatchError !== null)
|
||||
return {
|
||||
status: null,
|
||||
error: `dispatcher: ${dispatchError} (${response.status})`,
|
||||
dispatchReason: dispatchError,
|
||||
};
|
||||
return dispatchAnswer(dispatchError, response.status);
|
||||
|
||||
return answerFrom(response);
|
||||
} catch (err) {
|
||||
return failedAnswer(err);
|
||||
} finally {
|
||||
this.#release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Several calls in one request. A call may wait in the dispatcher's queue
|
||||
* for whatever is left of its connection wait, and its timeout runs from
|
||||
* its `started` line; the request itself is dropped once every call has an
|
||||
* answer.
|
||||
*/
|
||||
async #sendBatch(batch: BatchedCall[]): Promise<void> {
|
||||
const hangUp = new AbortController();
|
||||
let open = batch.length;
|
||||
const answered = batch.map(() => false);
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const finish = (i: number, done: (entry: BatchedCall) => void) => {
|
||||
if (answered[i]) return;
|
||||
answered[i] = true;
|
||||
clearTimeout(timers[i]);
|
||||
done(batch[i]);
|
||||
if (--open === 0) hangUp.abort();
|
||||
};
|
||||
const settle = (i: number, answer: EventsInvokeAnswer): void =>
|
||||
finish(i, (entry) => entry.resolve(answer));
|
||||
const settleRest = (answer: EventsInvokeAnswer): void => {
|
||||
for (let i = 0; i < batch.length; i++) settle(i, answer);
|
||||
};
|
||||
|
||||
const now = Date.now();
|
||||
const waits = batch.map(({ call, waitingSince }) =>
|
||||
Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
call.timeoutMs * CONNECTION_WAIT_SHARE -
|
||||
(now - waitingSince),
|
||||
),
|
||||
),
|
||||
);
|
||||
const body = JSON.stringify({
|
||||
calls: batch.map(({ call }, i) => ({
|
||||
script: call.script,
|
||||
appUid: call.appUid,
|
||||
...(call.holder ? { holder: call.holder } : {}),
|
||||
key: call.key,
|
||||
...(call.deployed ? { deployed: true } : {}),
|
||||
body: call.render ? call.render() : call.body,
|
||||
waitMs: waits[i],
|
||||
})),
|
||||
});
|
||||
const arm = (
|
||||
i: number,
|
||||
ms: number,
|
||||
answer: () => EventsInvokeAnswer,
|
||||
) => {
|
||||
clearTimeout(timers[i]);
|
||||
timers[i] = setTimeout(() => settle(i, answer()), ms);
|
||||
timers[i].unref?.();
|
||||
};
|
||||
// No `timedOut`: a call that never started never reached a handler.
|
||||
batch.forEach((_, i) =>
|
||||
arm(i, waits[i] + BATCH_START_GRACE_MS, () => ({
|
||||
status: null,
|
||||
error: 'dispatcher: call never started',
|
||||
})),
|
||||
);
|
||||
const started = (i: number): void => {
|
||||
if (!answered[i])
|
||||
arm(i, batch[i].call.timeoutMs, () =>
|
||||
failedAnswer(timeoutError()),
|
||||
);
|
||||
};
|
||||
const longest = Math.max(
|
||||
...batch.map(
|
||||
({ call }, i) =>
|
||||
waits[i] + BATCH_START_GRACE_MS + call.timeoutMs,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await this.#fetchImpl(
|
||||
`${this.#url}${EVENTS_DISPATCH_BATCH_PATH}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-puter-internal-auth': this.#secret,
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.any([
|
||||
hangUp.signal,
|
||||
AbortSignal.timeout(longest),
|
||||
]),
|
||||
dispatcher: this.#dispatcherFor(),
|
||||
},
|
||||
);
|
||||
|
||||
const dispatchError = response.headers.get(
|
||||
EVENTS_DISPATCH_ERROR_HEADER,
|
||||
);
|
||||
if (dispatchError === 'no-such-route') {
|
||||
// A dispatcher from before the batch route: nothing ran, so
|
||||
// every call goes again on its own.
|
||||
void response.body?.cancel().catch(() => {});
|
||||
this.#batchUnsupportedUntil =
|
||||
Date.now() + BATCH_UNSUPPORTED_RETRY_MS;
|
||||
batch.forEach(({ call }, i) => {
|
||||
if (answered[i]) return;
|
||||
const { beforeSend } = call;
|
||||
if (!beforeSend) {
|
||||
void this.#sendOne(call).then((a) => settle(i, a));
|
||||
return;
|
||||
}
|
||||
// The batch round trip may have outlived the call.
|
||||
void (async () => {
|
||||
try {
|
||||
if (!(await beforeSend()))
|
||||
return settle(i, supersededAnswer());
|
||||
} catch (err) {
|
||||
return finish(i, (entry) => entry.reject(err));
|
||||
}
|
||||
settle(i, await this.#sendOne(call));
|
||||
})();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (dispatchError !== null || !response.ok) {
|
||||
void response.body?.cancel().catch(() => {});
|
||||
// An unmarked failure (an edge 404, a WAF 403) reads as
|
||||
// Puter's, never as any handler's answer.
|
||||
settleRest(
|
||||
dispatchError !== null
|
||||
? dispatchAnswer(dispatchError, response.status)
|
||||
: {
|
||||
status: null,
|
||||
error: `dispatcher: unmarked ${response.status} for a batch`,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let buffered = '';
|
||||
const read = (line: string): void => {
|
||||
if (line.trim() === '') return;
|
||||
const parsed = parseAnswerLine(line);
|
||||
if (!parsed || parsed.i >= batch.length) return;
|
||||
if ('started' in parsed) started(parsed.i);
|
||||
else settle(parsed.i, parsed.answer);
|
||||
};
|
||||
const decoder = new TextDecoder();
|
||||
for await (const chunk of response.body ?? []) {
|
||||
buffered += decoder.decode(chunk, { stream: true });
|
||||
let newline = buffered.indexOf('\n');
|
||||
while (newline !== -1) {
|
||||
read(buffered.slice(0, newline));
|
||||
buffered = buffered.slice(newline + 1);
|
||||
newline = buffered.indexOf('\n');
|
||||
}
|
||||
}
|
||||
read(buffered);
|
||||
settleRest({
|
||||
status: null,
|
||||
error: 'dispatcher: batch ended without an answer',
|
||||
});
|
||||
} catch (err) {
|
||||
// Every call has its answer and the request was dropped on purpose.
|
||||
if (open === 0) return;
|
||||
settleRest(failedAnswer(err));
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a call on `lane` may take a connection now. */
|
||||
#free(lane: InvokeLane): boolean {
|
||||
return (
|
||||
this.#active < INVOKE_CONNECTIONS &&
|
||||
(lane !== 'schedule' ||
|
||||
this.#scheduleActive < SCHEDULE_INVOKE_CONNECTIONS)
|
||||
);
|
||||
}
|
||||
|
||||
#take(lane: InvokeLane): void {
|
||||
this.#active++;
|
||||
if (lane === 'schedule') this.#scheduleActive++;
|
||||
}
|
||||
|
||||
/** A connection slot, or false once `waitMs` passes without one. */
|
||||
#acquire(waitMs: number): Promise<boolean> {
|
||||
if (this.#active < INVOKE_CONNECTIONS) {
|
||||
this.#active++;
|
||||
#acquire(waitMs: number, lane: InvokeLane): Promise<boolean> {
|
||||
if (this.#free(lane)) {
|
||||
this.#take(lane);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
@@ -420,19 +925,24 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
resolve(false);
|
||||
}, waitMs);
|
||||
timer.unref?.();
|
||||
this.#waiting.add(granted);
|
||||
this.#waiting.set(granted, lane);
|
||||
});
|
||||
}
|
||||
|
||||
/** Hand the slot to the longest waiter, or give it back. */
|
||||
#release(): void {
|
||||
const next = this.#waiting.values().next().value;
|
||||
if (next) {
|
||||
this.#waiting.delete(next);
|
||||
next();
|
||||
/**
|
||||
* Give the slot back, then hand one to the longest waiter it can go to: a
|
||||
* schedule waiter at a full schedule share doesn't hold up a delivery.
|
||||
*/
|
||||
#release(lane: InvokeLane): void {
|
||||
this.#active--;
|
||||
if (lane === 'schedule') this.#scheduleActive--;
|
||||
for (const [granted, waiting] of this.#waiting) {
|
||||
if (!this.#free(waiting)) continue;
|
||||
this.#waiting.delete(granted);
|
||||
this.#take(waiting);
|
||||
granted();
|
||||
return;
|
||||
}
|
||||
this.#active--;
|
||||
}
|
||||
|
||||
/** One pool per process, built on first use and closed with the server. */
|
||||
@@ -446,6 +956,65 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/** What a failure the dispatcher marked as its own reads as. */
|
||||
const dispatchAnswer = (
|
||||
reason: string,
|
||||
status: number,
|
||||
): EventsInvokeAnswer => ({
|
||||
status: null,
|
||||
error: `dispatcher: ${reason} (${status})`,
|
||||
dispatchReason: reason,
|
||||
});
|
||||
|
||||
/** What `AbortSignal.timeout` rejects with, for a batched call's own timeout. */
|
||||
const timeoutError = (): Error =>
|
||||
new DOMException(
|
||||
'The operation was aborted due to timeout',
|
||||
'TimeoutError',
|
||||
);
|
||||
|
||||
/** One line of a batch answer, or null when it is not one. */
|
||||
export const parseAnswerLine = (
|
||||
line: string,
|
||||
):
|
||||
| { i: number; started: true }
|
||||
| { i: number; answer: EventsInvokeAnswer }
|
||||
| null => {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const { i, started, status, handled, error, retryAfter, dispatch } =
|
||||
raw as Record<string, unknown>;
|
||||
if (!Number.isInteger(i) || (i as number) < 0) return null;
|
||||
if (started === true) return { i: i as number, started: true };
|
||||
if (typeof status !== 'number') return null;
|
||||
if (typeof dispatch === 'string')
|
||||
return { i: i as number, answer: dispatchAnswer(dispatch, status) };
|
||||
return {
|
||||
i: i as number,
|
||||
answer: answerFrom({
|
||||
status,
|
||||
headers: {
|
||||
get: (name) => {
|
||||
if (name === EVENTS_HANDLED_HEADER)
|
||||
return handled === true ? '1' : null;
|
||||
if (name === EVENTS_ERROR_HEADER)
|
||||
return typeof error === 'string' ? error : null;
|
||||
if (name === 'retry-after')
|
||||
return typeof retryAfter === 'string'
|
||||
? retryAfter
|
||||
: null;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
/** Whether deploying the script is what fixes this answer. */
|
||||
const selfDeployable = (result: EventsInvokeAnswer): boolean =>
|
||||
result.status === null
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { Controller, Post } from '../../core/http/decorators.js';
|
||||
import { schedulesUnavailable } from '../../core/schedules.js';
|
||||
import type { BroadcastService } from '../../services/broadcast/BroadcastService.js';
|
||||
import type { ForwardBatch } from '../../services/events/forwardQueue.js';
|
||||
import { PuterController } from '../types.js';
|
||||
@@ -108,6 +109,42 @@ export class BroadcastController extends PuterController {
|
||||
);
|
||||
res.status(200).json({ ok: true, ...reply });
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule changes a region made for schedules this one owns, handed to
|
||||
* `services.schedules`; `events_disabled` on a server without it.
|
||||
*/
|
||||
@Post('/events/schedules', { subdomain: '*' })
|
||||
async schedules(req: Request, res: Response): Promise<void> {
|
||||
const broadcast = this.services.broadcast as unknown as
|
||||
BroadcastService | undefined;
|
||||
if (!broadcast) {
|
||||
res.status(503).json({
|
||||
error: { message: 'Broadcast service not registered' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const verified = await broadcast.verifySignedRequest(
|
||||
req.rawBody,
|
||||
broadcastHeaders(req),
|
||||
);
|
||||
if (!verified.ok) {
|
||||
res.status(verified.status ?? 400).json({
|
||||
error: { message: verified.message ?? 'Bad request' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (verified.info?.ignored) {
|
||||
res.status(200).json({ ok: true, ...verified.info });
|
||||
return;
|
||||
}
|
||||
|
||||
const schedules = this.services.schedules;
|
||||
if (!schedules) throw schedulesUnavailable();
|
||||
const reply = await schedules.receivePeerChanges(req.body);
|
||||
res.status(200).json({ ok: true, ...reply });
|
||||
}
|
||||
}
|
||||
|
||||
const broadcastHeaders = (req: Request) => {
|
||||
|
||||
@@ -25,6 +25,11 @@ import { bodyRecord } from '../../core/http/requestBody.js';
|
||||
import { DURABLE_LIST_LIMIT_CAP } from '../../stores/events/DurableSubscriptionStore.js';
|
||||
import { EVENTS_WORKERS_LIST_LIMIT_CAP } from '../../stores/events/EventHandlerStore.js';
|
||||
import { KV_HANDLE_LIST_LIMIT_CAP } from '../../stores/events/KvShareHandleStore.js';
|
||||
import {
|
||||
SCHEDULE_LIST_LIMIT_CAP,
|
||||
schedulesUnavailable,
|
||||
type ScheduleServiceContract,
|
||||
} from '../../core/schedules.js';
|
||||
import { normalizeLimit } from '../../util/pagination.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { EVENTS_WORKER_PREFIX } from '../../services/events/workerRuntime.js';
|
||||
@@ -59,6 +64,8 @@ const EVENTS_SCRIPT_REGEX = new RegExp(`^${EVENTS_WORKER_PREFIX}[a-f0-9]{32}$`);
|
||||
|
||||
/** Matches the edge dispatcher's own validation of the app uid it forwards. */
|
||||
const APP_UID_REGEX = /^app-[A-Za-z0-9_-]{1,64}$/;
|
||||
/** A user uuid. */
|
||||
const HOLDER_REGEX = /^[A-Za-z0-9-]{1,64}$/;
|
||||
|
||||
@Controller('/events')
|
||||
export class EventsController extends PuterController {
|
||||
@@ -127,7 +134,8 @@ export class EventsController extends PuterController {
|
||||
*
|
||||
* Stateless: no subscription, no stored position, nothing written. The
|
||||
* cursor comes back to the caller and goes out again as `cursor` (or
|
||||
* `after`, the name it first shipped under).
|
||||
* `after`, the name it first shipped under). `from=end` with no cursor
|
||||
* starts at the newest row instead of the oldest.
|
||||
*/
|
||||
@Get('/fetch', {
|
||||
subdomain: 'api',
|
||||
@@ -138,9 +146,16 @@ export class EventsController extends PuterController {
|
||||
async fetch(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
const from = query.from as 'start' | 'end' | undefined;
|
||||
if (from !== undefined && from !== 'start' && from !== 'end') {
|
||||
throw new HttpError(400, '`from` must be `start` or `end`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
const page = await this.services.events.fetchMissed(actor, {
|
||||
subject: typeof query.subject === 'string' ? query.subject : '',
|
||||
from,
|
||||
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
|
||||
after: typeof query.after === 'string' ? query.after : undefined,
|
||||
limit: normalizeLimit(query.limit, {
|
||||
@@ -169,6 +184,99 @@ export class EventsController extends PuterController {
|
||||
res.json({});
|
||||
}
|
||||
|
||||
// -- Schedules ---------------------------------------------------
|
||||
//
|
||||
// An app's handler run on a timer, served by `services.schedules` when a
|
||||
// server has it and answered `events_disabled` when it doesn't. Creating
|
||||
// one needs an app context; listing, reading and removing are scoped the
|
||||
// way the durable verbs are. The create and remove verbs spend the
|
||||
// subscribe and unsubscribe budgets in the service, shared with those
|
||||
// verbs.
|
||||
|
||||
#schedules(): ScheduleServiceContract {
|
||||
const schedules = this.services.schedules;
|
||||
if (!schedules) throw schedulesUnavailable();
|
||||
return schedules;
|
||||
}
|
||||
|
||||
/** POST /events/schedules — create a schedule for the calling app. */
|
||||
@Post('/schedules', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async createSchedule(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(await this.#schedules().createFor(actor, bodyRecord(req)));
|
||||
}
|
||||
|
||||
/** GET /events/schedules — the caller's schedules, one page at a time. */
|
||||
@Get('/schedules', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: EVENTS_LIST_LIMIT,
|
||||
})
|
||||
async listSchedules(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
|
||||
const page = await this.#schedules().list(actor, {
|
||||
limit: normalizeLimit(query.limit, {
|
||||
cap: SCHEDULE_LIST_LIMIT_CAP,
|
||||
}),
|
||||
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
|
||||
includeTotal: query.includeTotal === 'true',
|
||||
});
|
||||
|
||||
res.json({
|
||||
items: page.items,
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(page.total !== undefined ? { total: page.total } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('/schedules/update', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async updateSchedule(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const { scheduleId, changes } = bodyRecord(req);
|
||||
res.json(await this.#schedules().update(actor, scheduleId, changes));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /events/schedules/remove — end one schedule. An id the caller does
|
||||
* not hold reads as absent.
|
||||
*/
|
||||
@Post('/schedules/remove', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async removeSchedule(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
await this.#schedules().remove(actor, bodyRecord(req).scheduleId);
|
||||
res.json({});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /events/schedules/:scheduleId — one schedule, ended or not. An id the
|
||||
* caller does not hold reads as absent.
|
||||
*/
|
||||
@Get('/schedules/:scheduleId', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: EVENTS_LIST_LIMIT,
|
||||
})
|
||||
async getSchedule(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(await this.#schedules().get(actor, req.params.scheduleId));
|
||||
}
|
||||
|
||||
// -- Cross-user key-value handles --------------------------------
|
||||
|
||||
/**
|
||||
@@ -315,6 +423,7 @@ export class EventsController extends PuterController {
|
||||
handlers: await this.services.events.listHandlers(actor, {
|
||||
appUid:
|
||||
typeof query.appUid === 'string' ? query.appUid : undefined,
|
||||
scope: query.scope,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -413,6 +522,15 @@ export class EventsController extends PuterController {
|
||||
throw new HttpError(400, 'Missing or invalid `script`/`appUid`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
// Absent or empty is the app's own scope.
|
||||
const holder = body.holder === undefined ? '' : body.holder;
|
||||
if (
|
||||
typeof holder !== 'string' ||
|
||||
!(holder === '' || HOLDER_REGEX.test(holder))
|
||||
)
|
||||
throw new HttpError(400, 'Invalid `holder`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const deployer = this.#eventsWorkerDeployer();
|
||||
if (!deployer.enabled) {
|
||||
@@ -420,15 +538,25 @@ export class EventsController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
const outcome = await deployer.ensure(appUid, script);
|
||||
const scope = await deployer.scopeFor(appUid, holder);
|
||||
if (!scope) {
|
||||
res.status(404).json({ deployed: false, reason: 'no-holder' });
|
||||
return;
|
||||
}
|
||||
|
||||
const outcome = await deployer.ensure(scope, script);
|
||||
if (outcome === 'deployed') {
|
||||
res.json({ deployed: true });
|
||||
return;
|
||||
}
|
||||
// `failed` and `throttled` are worth another attempt: the rest say
|
||||
// this script is not what the app's handlers currently are.
|
||||
// `failed`, `throttled` and `busy` are worth another attempt: the rest
|
||||
// say this script is not what the app's handlers currently are.
|
||||
res.status(
|
||||
outcome === 'failed' || outcome === 'throttled' ? 502 : 404,
|
||||
outcome === 'failed' ||
|
||||
outcome === 'throttled' ||
|
||||
outcome === 'busy'
|
||||
? 502
|
||||
: 404,
|
||||
).json({
|
||||
deployed: false,
|
||||
reason: outcome,
|
||||
@@ -449,14 +577,22 @@ export class EventsController extends PuterController {
|
||||
override onServerStart(): void {
|
||||
const deployer = this.#eventsWorkerDeployer();
|
||||
if (!deployer.enabled) return;
|
||||
this.services.events.useEagerDeploy((scope, script) =>
|
||||
deployer.deployEagerly(scope, script),
|
||||
);
|
||||
if (this.config.workers?.localServer) {
|
||||
this.services.events.useWorkerTransport(
|
||||
new LocalEventsInvokeTransport(this.services, deployer),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.services.events.useWorkerMissHandler((appUid, script) =>
|
||||
deployer.ensure(appUid, script),
|
||||
this.services.events.useWorkerMissHandler(
|
||||
async (appUid, script, options) => {
|
||||
const scope = await deployer.scopeFor(appUid, options.holder);
|
||||
return scope
|
||||
? deployer.ensure(scope, script, options)
|
||||
: 'no-holder';
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,23 +34,46 @@ import {
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_MAX,
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_APP,
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER,
|
||||
EVENTS_HANDLER_PUBLISH_LIMIT,
|
||||
EVENTS_HANDLER_SOURCE_MAX_BYTES,
|
||||
EVENTS_HANDLERS_PER_APP,
|
||||
EVENTS_KV_HANDLES_PER_APP,
|
||||
EVENTS_KV_HANDLES_PER_USER,
|
||||
EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT,
|
||||
EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT,
|
||||
EVENTS_SINGLE_DELIVERY_LIMIT,
|
||||
EVENTS_USER_HANDLERS_PER_SCOPE,
|
||||
EVENTS_USER_SCOPE_SOURCE_MAX_BYTES,
|
||||
EVENTS_USER_WORKER_DEPLOYS_PER_HOUR,
|
||||
EVENTS_WORKER_DEPLOYS_PER_HOUR,
|
||||
EVENTS_WORKER_INVOCATION_LIMIT,
|
||||
EVENTS_WORKER_SOURCE_MAX_BYTES,
|
||||
RENTED_UNITS_PER_USER,
|
||||
limitFor,
|
||||
tiered,
|
||||
type TieredLimit,
|
||||
} from './limits.js';
|
||||
|
||||
const tiers: Array<[string, TieredLimit]> = [
|
||||
['durable subscriptions per account', EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER],
|
||||
[
|
||||
'durable subscriptions per account',
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER,
|
||||
],
|
||||
['durable subscriptions per app', EVENTS_DURABLE_SUBSCRIPTIONS_PER_APP],
|
||||
['live share handles per account', EVENTS_KV_HANDLES_PER_USER],
|
||||
['live share handles per app', EVENTS_KV_HANDLES_PER_APP],
|
||||
];
|
||||
|
||||
describe('tiered', () => {
|
||||
it('gives paid plans the base, and the free and temporary tiers their own', () => {
|
||||
const limit = tiered(30, 20, 10);
|
||||
expect(limitFor(limit, null)).toBe(30);
|
||||
expect(limitFor(limit, 'some_plan')).toBe(30);
|
||||
expect(limitFor(limit, DEFAULT_FREE_SUBSCRIPTION)).toBe(20);
|
||||
expect(limitFor(limit, DEFAULT_TEMP_SUBSCRIPTION)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the tiered subscription quotas', () => {
|
||||
it.each(tiers)('%s never lets a free tier exceed paid', (_name, tier) => {
|
||||
for (const n of Object.values(tier.bySubscription))
|
||||
@@ -105,9 +128,9 @@ describe('the tiered subscription quotas', () => {
|
||||
});
|
||||
|
||||
it('holds an unrecognised plan to the paid base', () => {
|
||||
expect(limitFor(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER, 'some_plan')).toBe(
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit,
|
||||
);
|
||||
expect(
|
||||
limitFor(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER, 'some_plan'),
|
||||
).toBe(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit);
|
||||
expect(limitFor(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER, null)).toBe(
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit,
|
||||
);
|
||||
@@ -131,9 +154,9 @@ describe('the delivery budgets', () => {
|
||||
expect(
|
||||
limitFor(EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT, plan),
|
||||
).toBe(matched);
|
||||
expect(
|
||||
limitFor(EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT, plan),
|
||||
).toBe(evaluated);
|
||||
expect(limitFor(EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT, plan)).toBe(
|
||||
evaluated,
|
||||
);
|
||||
expect(evaluated).toBeGreaterThanOrEqual(matched);
|
||||
}
|
||||
});
|
||||
@@ -169,3 +192,42 @@ describe('the delivery budgets', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the handler limits', () => {
|
||||
it('pins the app-scope numbers the quotas page lists', () => {
|
||||
expect(EVENTS_HANDLERS_PER_APP).toBe(100);
|
||||
expect(EVENTS_HANDLER_SOURCE_MAX_BYTES).toBe(64 * 1024);
|
||||
expect(EVENTS_WORKER_SOURCE_MAX_BYTES).toBe(5 * 1024 * 1024);
|
||||
expect(EVENTS_WORKER_DEPLOYS_PER_HOUR).toBe(30);
|
||||
expect(EVENTS_HANDLER_PUBLISH_LIMIT.limit).toBe(60);
|
||||
expect(EVENTS_HANDLER_PUBLISH_LIMIT.window).toBe(60_000);
|
||||
});
|
||||
|
||||
it('pins the user-scope numbers the quotas page lists', () => {
|
||||
expect(EVENTS_USER_HANDLERS_PER_SCOPE).toBe(20);
|
||||
expect(EVENTS_USER_SCOPE_SOURCE_MAX_BYTES).toBe(1024 * 1024);
|
||||
expect(EVENTS_USER_WORKER_DEPLOYS_PER_HOUR).toBe(10);
|
||||
});
|
||||
|
||||
it('keeps a user scope tighter than an app scope', () => {
|
||||
expect(EVENTS_USER_HANDLERS_PER_SCOPE).toBeLessThan(
|
||||
EVENTS_HANDLERS_PER_APP,
|
||||
);
|
||||
expect(EVENTS_USER_SCOPE_SOURCE_MAX_BYTES).toBeLessThan(
|
||||
EVENTS_WORKER_SOURCE_MAX_BYTES,
|
||||
);
|
||||
expect(EVENTS_USER_WORKER_DEPLOYS_PER_HOUR).toBeLessThan(
|
||||
EVENTS_WORKER_DEPLOYS_PER_HOUR,
|
||||
);
|
||||
});
|
||||
|
||||
it('pins the rented-unit cap by plan', () => {
|
||||
expect(RENTED_UNITS_PER_USER.limit).toBe(100);
|
||||
expect(limitFor(RENTED_UNITS_PER_USER, DEFAULT_FREE_SUBSCRIPTION)).toBe(
|
||||
16,
|
||||
);
|
||||
expect(limitFor(RENTED_UNITS_PER_USER, DEFAULT_TEMP_SUBSCRIPTION)).toBe(
|
||||
16,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,12 @@ export interface TieredLimit {
|
||||
bySubscription: Record<string, number>;
|
||||
}
|
||||
|
||||
const tiered = (paid: number, free: number, temp: number): TieredLimit => ({
|
||||
/** Paid, free and temporary caps as a {@link TieredLimit}. Called by extensions. */
|
||||
export const tiered = (
|
||||
paid: number,
|
||||
free: number,
|
||||
temp: number,
|
||||
): TieredLimit => ({
|
||||
limit: paid,
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: free,
|
||||
@@ -264,6 +269,12 @@ export const KV_SHARE_NOTIFY_LIMITS = [
|
||||
*/
|
||||
export const EVENTS_HANDLERS_PER_APP = 100;
|
||||
|
||||
/**
|
||||
* Handlers one user may hold in their own scope of one app. Each is a script of
|
||||
* its own, so the cap is tighter than the app's.
|
||||
*/
|
||||
export const EVENTS_USER_HANDLERS_PER_SCOPE = 20;
|
||||
|
||||
/** Longest a serialized handler may be. */
|
||||
export const EVENTS_HANDLER_SOURCE_MAX_BYTES = 64 * 1024;
|
||||
|
||||
@@ -275,6 +286,9 @@ export const EVENTS_HANDLER_SOURCE_MAX_BYTES = 64 * 1024;
|
||||
*/
|
||||
export const EVENTS_WORKER_SOURCE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/** Source one user scope may hold, all its handlers combined. */
|
||||
export const EVENTS_USER_SCOPE_SOURCE_MAX_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Deploys one app's events worker may trigger per hour.
|
||||
*
|
||||
@@ -284,6 +298,16 @@ export const EVENTS_WORKER_SOURCE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
*/
|
||||
export const EVENTS_WORKER_DEPLOYS_PER_HOUR = 30;
|
||||
|
||||
/** Deploys one user scope may trigger per hour. */
|
||||
export const EVENTS_USER_WORKER_DEPLOYS_PER_HOUR = 10;
|
||||
|
||||
/**
|
||||
* Units one user may rent at once: ordinary workers, user scopes, and the
|
||||
* events workers of apps they own. Only creating a unit is refused; holdings
|
||||
* over the cap are kept.
|
||||
*/
|
||||
export const RENTED_UNITS_PER_USER = tiered(100, 16, 16);
|
||||
|
||||
/**
|
||||
* Handlers one `publishAll` may carry. A build step publishes its whole set in
|
||||
* one call, and the set is capped by what an app may hold anyway.
|
||||
@@ -448,11 +472,13 @@ export const EVENTS_REGION_PENDING_CEILING = 1_000_000;
|
||||
// finish backs off the same way but is not the handler's failure, so it never
|
||||
// counts toward that run.
|
||||
|
||||
/** Wait before the first retry of a delivery a handler could not take. */
|
||||
export const EVENTS_RETRY_BASE_MS = 2_000;
|
||||
|
||||
/** Longest wait between retries, however many have failed. */
|
||||
export const EVENTS_RETRY_MAX_MS = 5 * 60 * 1000;
|
||||
// Wait before the first retry of a delivery a handler could not take, the
|
||||
// longest wait, and the doubling between them. Shared with system jobs.
|
||||
export {
|
||||
deliveryBackoffMs,
|
||||
EVENTS_RETRY_BASE_MS,
|
||||
EVENTS_RETRY_MAX_MS,
|
||||
} from '../../util/retryBackoff.js';
|
||||
|
||||
/**
|
||||
* Handler failures in a row before a subscription is suspended. Counted per
|
||||
@@ -468,13 +494,6 @@ export const EVENTS_CONSECUTIVE_FAILURES = 5;
|
||||
*/
|
||||
export const EVENTS_FAILURE_COUNTER_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
/** How long to hold a delivery whose handler has failed `attempts` times. */
|
||||
export const deliveryBackoffMs = (attempts: number): number =>
|
||||
Math.min(
|
||||
EVENTS_RETRY_MAX_MS,
|
||||
EVENTS_RETRY_BASE_MS * 2 ** Math.max(0, attempts - 1),
|
||||
);
|
||||
|
||||
// -- Suspended backlog -----------------------------------------------
|
||||
//
|
||||
// A suspended subscription stops metering but keeps what it is owed, and that
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The schedule routes are thin delegates: without `services.schedules` every
|
||||
* one answers 503 `events_disabled`; with it, each hands its request over and
|
||||
* answers what it returns.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { isHttpError } from '../../core/http/HttpError.js';
|
||||
import type {
|
||||
ScheduleServiceContract,
|
||||
ScheduleView,
|
||||
} from '../../core/schedules.js';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import type { BroadcastController } from '../broadcast/BroadcastController.js';
|
||||
import type { EventsController } from './EventsController.js';
|
||||
|
||||
let server: PuterServer;
|
||||
let events: EventsController;
|
||||
let broadcast: BroadcastController;
|
||||
let actor: Actor;
|
||||
let saved: unknown;
|
||||
|
||||
const SCHEDULE_ID = '0b3a3c9e-9f61-4f0e-8d2a-2f1b6f8f2c11';
|
||||
|
||||
const view = {
|
||||
scheduleId: SCHEDULE_ID,
|
||||
kind: 'recurring',
|
||||
appUid: 'app-x',
|
||||
} as ScheduleView;
|
||||
|
||||
const slot = () => server.services as unknown as { schedules?: unknown };
|
||||
/** Put `schedules` in the slot, or leave the slot empty. */
|
||||
const setSchedules = (schedules: unknown): void => {
|
||||
if (schedules === undefined) delete slot().schedules;
|
||||
else slot().schedules = schedules;
|
||||
};
|
||||
|
||||
const fakeSchedules = () =>
|
||||
({
|
||||
onSubscriptionsChanged: vi.fn(async () => {}),
|
||||
createFor: vi.fn(async () => view),
|
||||
list: vi.fn(async () => ({
|
||||
items: [view],
|
||||
cursor: 'next',
|
||||
total: 3,
|
||||
})),
|
||||
get: vi.fn(async () => view),
|
||||
update: vi.fn(async () => view),
|
||||
remove: vi.fn(async () => {}),
|
||||
receivePeerChanges: vi.fn(async () => ({ applied: 2 })),
|
||||
prepareSendAt: vi.fn(),
|
||||
createDeferredAppMessage: vi.fn(),
|
||||
}) satisfies ScheduleServiceContract;
|
||||
|
||||
const req = (init: {
|
||||
body?: unknown;
|
||||
query?: Record<string, unknown>;
|
||||
params?: Record<string, string>;
|
||||
}): Request =>
|
||||
({
|
||||
actor,
|
||||
body: init.body ?? {},
|
||||
query: init.query ?? {},
|
||||
params: init.params ?? {},
|
||||
headers: {},
|
||||
rawBody: Buffer.from(JSON.stringify(init.body ?? {})),
|
||||
}) as unknown as Request;
|
||||
|
||||
const res = () => {
|
||||
const sent: { status: number; body: unknown } = {
|
||||
status: 200,
|
||||
body: undefined,
|
||||
};
|
||||
const response = {
|
||||
json: vi.fn((body: unknown) => {
|
||||
sent.body = body;
|
||||
return response;
|
||||
}),
|
||||
status: vi.fn((code: number) => {
|
||||
sent.status = code;
|
||||
return response;
|
||||
}),
|
||||
setHeader: vi.fn(() => response),
|
||||
};
|
||||
return { response: response as unknown as Response, sent };
|
||||
};
|
||||
|
||||
/** Each route, called as the router would. */
|
||||
const routes: Array<[string, (response: Response) => Promise<void>]> = [
|
||||
[
|
||||
'POST /events/schedules',
|
||||
(r) => events.createSchedule(req({ body: { every: 60_000 } }), r),
|
||||
],
|
||||
[
|
||||
'GET /events/schedules',
|
||||
(r) => events.listSchedules(req({ query: { limit: '5' } }), r),
|
||||
],
|
||||
[
|
||||
'GET /events/schedules/:scheduleId',
|
||||
(r) =>
|
||||
events.getSchedule(req({ params: { scheduleId: SCHEDULE_ID } }), r),
|
||||
],
|
||||
[
|
||||
'POST /events/schedules/update',
|
||||
(r) => events.updateSchedule(req({ body: { scheduleId: SCHEDULE_ID, changes: { every: '2h' } } }), r),
|
||||
],
|
||||
[
|
||||
'POST /events/schedules/remove',
|
||||
(r) =>
|
||||
events.removeSchedule(
|
||||
req({ body: { scheduleId: SCHEDULE_ID } }),
|
||||
r,
|
||||
),
|
||||
],
|
||||
[
|
||||
'POST /broadcast/events/schedules',
|
||||
(r) =>
|
||||
broadcast.schedules(
|
||||
req({ body: { namespace: 'n', changes: [] } }),
|
||||
r,
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
events = server.controllers.events as unknown as EventsController;
|
||||
broadcast = server.controllers.broadcast as unknown as BroadcastController;
|
||||
actor = makeActor({
|
||||
user: { id: 1, uuid: 'u-1', username: 'someone' },
|
||||
} as never);
|
||||
saved = slot().schedules;
|
||||
vi.spyOn(
|
||||
server.services.broadcast,
|
||||
'verifySignedRequest',
|
||||
).mockResolvedValue({ ok: true, peerId: 'peer' } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setSchedules(saved);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
describe('without services.schedules', () => {
|
||||
it.each(routes)('%s answers 503 events_disabled', async (_name, call) => {
|
||||
setSchedules(undefined);
|
||||
const { response } = res();
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await call(response);
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(isHttpError(thrown)).toBe(true);
|
||||
expect(thrown).toMatchObject({
|
||||
statusCode: 503,
|
||||
legacyCode: 'events_disabled',
|
||||
// The designed answer, so it never pages.
|
||||
noAlarm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses an unsigned peer batch before saying schedules are off', async () => {
|
||||
setSchedules(undefined);
|
||||
vi.spyOn(
|
||||
server.services.broadcast,
|
||||
'verifySignedRequest',
|
||||
).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
message: 'bad signature',
|
||||
} as never);
|
||||
const { response, sent } = res();
|
||||
await broadcast.schedules(
|
||||
req({ body: { namespace: 'n', changes: [] } }),
|
||||
response,
|
||||
);
|
||||
expect(sent.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with services.schedules', () => {
|
||||
it('creates through createFor and answers its view', async () => {
|
||||
const schedules = fakeSchedules();
|
||||
setSchedules(schedules);
|
||||
const { response, sent } = res();
|
||||
await events.createSchedule(req({ body: { every: 60_000 } }), response);
|
||||
expect(schedules.createFor).toHaveBeenCalledWith(actor, {
|
||||
every: 60_000,
|
||||
});
|
||||
expect(sent.body).toEqual(view);
|
||||
});
|
||||
|
||||
it('lists with the limit capped, the cursor and includeTotal', async () => {
|
||||
const schedules = fakeSchedules();
|
||||
setSchedules(schedules);
|
||||
const { response, sent } = res();
|
||||
await events.listSchedules(
|
||||
req({
|
||||
query: { limit: '5000', cursor: 'c', includeTotal: 'true' },
|
||||
}),
|
||||
response,
|
||||
);
|
||||
expect(schedules.list).toHaveBeenCalledWith(actor, {
|
||||
limit: 200,
|
||||
cursor: 'c',
|
||||
includeTotal: true,
|
||||
});
|
||||
expect(sent.body).toEqual({ items: [view], cursor: 'next', total: 3 });
|
||||
});
|
||||
|
||||
it('reads one through get', async () => {
|
||||
const schedules = fakeSchedules();
|
||||
setSchedules(schedules);
|
||||
const { response, sent } = res();
|
||||
await events.getSchedule(
|
||||
req({ params: { scheduleId: SCHEDULE_ID } }),
|
||||
response,
|
||||
);
|
||||
expect(schedules.get).toHaveBeenCalledWith(actor, SCHEDULE_ID);
|
||||
expect(sent.body).toEqual(view);
|
||||
});
|
||||
|
||||
it('updates through update and answers its view', async () => {
|
||||
const schedules = fakeSchedules();
|
||||
setSchedules(schedules);
|
||||
const { response, sent } = res();
|
||||
const changes = { every: '2h' };
|
||||
await events.updateSchedule(req({ body: { scheduleId: SCHEDULE_ID, changes } }), response);
|
||||
expect(schedules.update).toHaveBeenCalledWith(actor, SCHEDULE_ID, changes);
|
||||
expect(sent.body).toEqual(view);
|
||||
});
|
||||
|
||||
it('removes through remove and answers {}', async () => {
|
||||
const schedules = fakeSchedules();
|
||||
setSchedules(schedules);
|
||||
const { response, sent } = res();
|
||||
await events.removeSchedule(
|
||||
req({ body: { scheduleId: SCHEDULE_ID } }),
|
||||
response,
|
||||
);
|
||||
expect(schedules.remove).toHaveBeenCalledWith(actor, SCHEDULE_ID);
|
||||
expect(sent.body).toEqual({});
|
||||
});
|
||||
|
||||
it('hands a signed peer batch to receivePeerChanges', async () => {
|
||||
const schedules = fakeSchedules();
|
||||
setSchedules(schedules);
|
||||
const { response, sent } = res();
|
||||
const body = { namespace: 'n', changes: [] };
|
||||
await broadcast.schedules(req({ body }), response);
|
||||
expect(schedules.receivePeerChanges).toHaveBeenCalledWith(body);
|
||||
expect(sent).toEqual({ status: 200, body: { ok: true, applied: 2 } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import type {
|
||||
ScheduleServiceContract,
|
||||
ScheduleView,
|
||||
} from '../../core/schedules.js';
|
||||
import { EventsController } from './EventsController.js';
|
||||
|
||||
const actor = makeActor({
|
||||
user: { id: 1, uuid: 'user-1', username: 'user1' } as never,
|
||||
});
|
||||
const request = (body: unknown) => ({ actor, body }) as Request;
|
||||
const controller = (schedules?: Pick<ScheduleServiceContract, 'update'>) =>
|
||||
new EventsController(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ schedules } as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
describe('update schedule route contract', () => {
|
||||
it('answers events_disabled when schedules are unavailable', async () => {
|
||||
await expect(
|
||||
controller().updateSchedule(
|
||||
request({ scheduleId: 'id', changes: {} }),
|
||||
{} as Response,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
legacyCode: 'events_disabled',
|
||||
statusCode: 503,
|
||||
noAlarm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the actor, id and changes through and returns the updated view', async () => {
|
||||
const view = {
|
||||
scheduleId: 'id',
|
||||
kind: 'recurring',
|
||||
state: 'suspended',
|
||||
} as ScheduleView;
|
||||
const changes = { cron: '0 9 * * *', tz: 'UTC' };
|
||||
const calls: unknown[][] = [];
|
||||
const events = controller({
|
||||
update: async (...args) => {
|
||||
calls.push(args);
|
||||
return view;
|
||||
},
|
||||
});
|
||||
let sent: unknown;
|
||||
await events.updateSchedule(request({ scheduleId: 'id', changes }), {
|
||||
json: (value: unknown) => {
|
||||
sent = value;
|
||||
},
|
||||
} as Response);
|
||||
expect(calls).toEqual([[actor, 'id', changes]]);
|
||||
expect(sent).toBe(view);
|
||||
});
|
||||
|
||||
it('preserves validation and sending errors from the implementation', async () => {
|
||||
const error = {
|
||||
code: 'events_schedule_sending',
|
||||
message: 'Already sending',
|
||||
};
|
||||
await expect(
|
||||
controller({
|
||||
update: async () => {
|
||||
throw error;
|
||||
},
|
||||
}).updateSchedule(
|
||||
request({ scheduleId: 'id', changes: {} }),
|
||||
{} as Response,
|
||||
),
|
||||
).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
@@ -25,9 +25,35 @@
|
||||
|
||||
import MockRedis from 'ioredis-mock';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
|
||||
import { EventsWorkerDeployer } from './workerDeploy.js';
|
||||
import { eventsWorkerScript } from '../../services/events/workerRuntime.js';
|
||||
import type { fetch as undiciFetch } from 'undici';
|
||||
import {
|
||||
DispatcherInvokeTransport,
|
||||
EVENTS_DEPLOYED_HEADER,
|
||||
EVENTS_DISPATCH_ERROR_HEADER,
|
||||
EVENTS_HANDLED_HEADER,
|
||||
EventsWorkerInvokerClient,
|
||||
type EventsInvokeCall,
|
||||
} from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import { INTERNAL_DEPLOY_TARGET } from '../../drivers/workers/WorkerDriver.js';
|
||||
import {
|
||||
EVENTS_USER_WORKER_DEPLOYS_PER_HOUR,
|
||||
EVENTS_WORKER_DEPLOYS_PER_HOUR,
|
||||
} from './limits.js';
|
||||
import {
|
||||
EventsWorkerDeployer,
|
||||
LocalEventsInvokeTransport,
|
||||
type EventsDeployOptions,
|
||||
type EventsDeployOutcome,
|
||||
} from './workerDeploy.js';
|
||||
import {
|
||||
eventsWorkerScript,
|
||||
userHandlerScript,
|
||||
} from '../../services/events/workerRuntime.js';
|
||||
import {
|
||||
appHandlerScope,
|
||||
hashContent,
|
||||
type HandlerScope,
|
||||
} from '../../stores/events/EventHandlerStore.js';
|
||||
import { handlerSetHash } from '../../services/events/workerSource.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
|
||||
@@ -46,14 +72,22 @@ interface Owner {
|
||||
type Layers = ConstructorParameters<typeof EventsWorkerDeployer>[0];
|
||||
|
||||
/** Stub layers whose only real dependency is a fresh redis mock. */
|
||||
const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
const makeLayers = (
|
||||
owners: Record<string, Owner | null>,
|
||||
events: Record<string, unknown> = {},
|
||||
) => {
|
||||
const redis = new MockRedis.Cluster(['redis://localhost:7001']);
|
||||
const created: string[] = [];
|
||||
const targets: Array<Record<string, unknown>> = [];
|
||||
/** Apps whose handler sources were read, once per read. */
|
||||
const sourceReads: string[] = [];
|
||||
const layers = {
|
||||
config: {
|
||||
events: { workerRuntime: true, internalSecret: 'sekret' },
|
||||
events: {
|
||||
workerRuntime: true,
|
||||
internalSecret: 'sekret',
|
||||
...events,
|
||||
},
|
||||
// `#namespace()` needs a local-worker shape to resolve without
|
||||
// throwing — this stub never reaches a real Cloudflare or
|
||||
// Miniflare deploy either way.
|
||||
@@ -63,7 +97,7 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
stores: {
|
||||
eventHandler: {
|
||||
setForApp: async () => [{ name: 'h', sourceHash: SOURCE_HASH }],
|
||||
allForApp: async (appUid: string) => {
|
||||
allForApp: async ({ appUid }: { appUid: string }) => {
|
||||
sourceReads.push(appUid);
|
||||
return [
|
||||
{
|
||||
@@ -86,6 +120,18 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
},
|
||||
},
|
||||
user: {
|
||||
getByUuid: async (uuid: string) => {
|
||||
const owner = Object.values(owners).find(
|
||||
(o) => `uuid-${o?.ownerUserId}` === uuid,
|
||||
);
|
||||
if (!owner) return null;
|
||||
return {
|
||||
id: owner.ownerUserId,
|
||||
uuid,
|
||||
username: `user-${owner.ownerUserId}`,
|
||||
suspended: owner.suspended ?? false,
|
||||
};
|
||||
},
|
||||
getById: async (id: number) => {
|
||||
const owner = Object.values(owners).find(
|
||||
(o) => o?.ownerUserId === id,
|
||||
@@ -103,8 +149,12 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
services: {},
|
||||
drivers: {
|
||||
workers: {
|
||||
create: async (args: { workerName: string }) => {
|
||||
create: async (args: {
|
||||
workerName: string;
|
||||
[INTERNAL_DEPLOY_TARGET]?: Record<string, unknown>;
|
||||
}) => {
|
||||
created.push(args.workerName);
|
||||
targets.push(args[INTERNAL_DEPLOY_TARGET] ?? {});
|
||||
return { success: true, errors: [] };
|
||||
},
|
||||
},
|
||||
@@ -113,6 +163,7 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
return {
|
||||
layers: layers as unknown as Layers,
|
||||
created,
|
||||
targets,
|
||||
redis,
|
||||
sourceReads,
|
||||
};
|
||||
@@ -131,8 +182,8 @@ describe('EventsWorkerDeployer.ensure', () => {
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
deployer.ensure('app-a', SCRIPT),
|
||||
deployer.ensure('app-b', SCRIPT),
|
||||
deployer.ensure(appHandlerScope('app-a'), SCRIPT),
|
||||
deployer.ensure(appHandlerScope('app-b'), SCRIPT),
|
||||
]);
|
||||
expect(a).toBe('deployed');
|
||||
expect(b).toBe('no-owner');
|
||||
@@ -149,7 +200,9 @@ describe('EventsWorkerDeployer.ensure', () => {
|
||||
String(EVENTS_WORKER_DEPLOYS_PER_HOUR),
|
||||
);
|
||||
|
||||
expect(await deployer.ensure('app-c', SCRIPT)).toBe('throttled');
|
||||
expect(await deployer.ensure(appHandlerScope('app-c'), SCRIPT)).toBe(
|
||||
'throttled',
|
||||
);
|
||||
expect(sourceReads).toEqual([]);
|
||||
expect(created).toEqual([]);
|
||||
});
|
||||
@@ -164,15 +217,680 @@ describe('EventsWorkerDeployer.ensure', () => {
|
||||
'',
|
||||
);
|
||||
|
||||
expect(await deployer.ensure('app-e', stale)).toBe('stale');
|
||||
expect(await deployer.ensure(appHandlerScope('app-e'), stale)).toBe(
|
||||
'stale',
|
||||
);
|
||||
expect(sourceReads).toEqual([]);
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
expect(await redis.get(`ev:deploys:{app-e}:${hour}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('passes events.workerCpuMs to the deploy target', async () => {
|
||||
const { layers, targets } = makeLayers(
|
||||
{ 'app-f': { ownerUserId: 6 } },
|
||||
{ workerCpuMs: 50 },
|
||||
);
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
expect(await deployer.ensure(appHandlerScope('app-f'), SCRIPT)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(targets[0]!.cpuMs).toBe(50);
|
||||
});
|
||||
|
||||
it('passes no cpuMs when events.workerCpuMs is unset', async () => {
|
||||
const { layers, targets } = makeLayers({ 'app-g': { ownerUserId: 7 } });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
expect(await deployer.ensure(appHandlerScope('app-g'), SCRIPT)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(targets[0]!.cpuMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deploys normally under the cap', async () => {
|
||||
const { layers } = makeLayers({ 'app-d': { ownerUserId: 4 } });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
expect(await deployer.ensure('app-d', SCRIPT)).toBe('deployed');
|
||||
expect(await deployer.ensure(appHandlerScope('app-d'), SCRIPT)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** A server's schedules with a deploy gate that records and can refuse. */
|
||||
const withGate = (
|
||||
layers: Layers,
|
||||
refuse: (capped: boolean) => boolean = () => false,
|
||||
) => {
|
||||
const gated: Array<{ script: string; capped: boolean }> = [];
|
||||
const gate = {
|
||||
gateDeploy: async (
|
||||
script: string,
|
||||
options: { capped: boolean },
|
||||
deploy: () => Promise<EventsDeployOutcome>,
|
||||
): Promise<EventsDeployOutcome> => {
|
||||
gated.push({ script, capped: options.capped });
|
||||
return refuse(options.capped) ? 'busy' : deploy();
|
||||
},
|
||||
};
|
||||
const deployer = new EventsWorkerDeployer({
|
||||
...layers,
|
||||
services: { ...layers.services, schedules: gate },
|
||||
} as unknown as Layers);
|
||||
return { deployer, gated };
|
||||
};
|
||||
|
||||
describe('the schedules` deploy gate', () => {
|
||||
it('is skipped without schedules, or with schedules that have no gate', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
|
||||
await expect(
|
||||
new EventsWorkerDeployer(layers).ensure(
|
||||
appHandlerScope('app-x'),
|
||||
SCRIPT,
|
||||
{
|
||||
lane: 'schedule',
|
||||
},
|
||||
),
|
||||
).resolves.toBe('deployed');
|
||||
const gateless = new EventsWorkerDeployer({
|
||||
...layers,
|
||||
services: { ...layers.services, schedules: {} },
|
||||
} as unknown as Layers);
|
||||
await expect(
|
||||
gateless.ensure(appHandlerScope('app-x'), SCRIPT, {
|
||||
lane: 'schedule',
|
||||
}),
|
||||
).resolves.toBe('deployed');
|
||||
expect(created).toEqual([SCRIPT, SCRIPT]);
|
||||
});
|
||||
|
||||
it('takes every deploy, capped only for a schedule run', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
|
||||
const { deployer, gated } = withGate(layers);
|
||||
|
||||
await expect(
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT),
|
||||
).resolves.toBe('deployed');
|
||||
await expect(
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT, {
|
||||
lane: 'schedule',
|
||||
}),
|
||||
).resolves.toBe('deployed');
|
||||
expect(gated).toEqual([
|
||||
{ script: SCRIPT, capped: false },
|
||||
{ script: SCRIPT, capped: true },
|
||||
]);
|
||||
expect(created).toEqual([SCRIPT, SCRIPT]);
|
||||
});
|
||||
|
||||
it('answers a refused schedule run busy, and a delivery still deploys', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
|
||||
const { deployer } = withGate(layers, (capped) => capped);
|
||||
|
||||
await expect(
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT, {
|
||||
lane: 'schedule',
|
||||
}),
|
||||
).resolves.toBe('busy');
|
||||
await expect(
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT),
|
||||
).resolves.toBe('deployed');
|
||||
expect(created).toEqual([SCRIPT]);
|
||||
});
|
||||
|
||||
it('lets deliveries that join a schedule run`s refused deploy deploy once', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
|
||||
const { deployer, gated } = withGate(layers, (capped) => capped);
|
||||
|
||||
const scheduled = deployer.ensure(appHandlerScope('app-x'), SCRIPT, {
|
||||
lane: 'schedule',
|
||||
});
|
||||
const deliveries = [
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT),
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT),
|
||||
];
|
||||
await expect(scheduled).resolves.toBe('busy');
|
||||
await expect(Promise.all(deliveries)).resolves.toEqual([
|
||||
'deployed',
|
||||
'deployed',
|
||||
]);
|
||||
expect(created).toEqual([SCRIPT]);
|
||||
expect(gated.map((g) => g.capped)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('lets a schedule run join a delivery`s deploy in flight', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
|
||||
const { deployer, gated } = withGate(layers, (capped) => capped);
|
||||
|
||||
const delivery = deployer.ensure(appHandlerScope('app-x'), SCRIPT);
|
||||
const scheduled = deployer.ensure(appHandlerScope('app-x'), SCRIPT, {
|
||||
lane: 'schedule',
|
||||
});
|
||||
await expect(Promise.all([delivery, scheduled])).resolves.toEqual([
|
||||
'deployed',
|
||||
'deployed',
|
||||
]);
|
||||
expect(gated).toEqual([{ script: SCRIPT, capped: false }]);
|
||||
expect(created).toEqual([SCRIPT]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LocalEventsInvokeTransport', () => {
|
||||
it('renders each request as it goes out, after a cold deploy', async () => {
|
||||
const bodies: string[] = [];
|
||||
let resident = false;
|
||||
const transport = new LocalEventsInvokeTransport(
|
||||
{
|
||||
localworkerservice: {
|
||||
dispatchEventsWorker: async (request: Request) => {
|
||||
bodies.push(await request.text());
|
||||
if (!resident) return null;
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'x-puter-events-handled': '1' },
|
||||
});
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
ensure: async () => {
|
||||
resident = true;
|
||||
return 'deployed';
|
||||
},
|
||||
scopeFor: async (appUid: string) => appHandlerScope(appUid),
|
||||
} as unknown as EventsWorkerDeployer,
|
||||
);
|
||||
let renders = 0;
|
||||
const call: EventsInvokeCall = {
|
||||
script: SCRIPT,
|
||||
appUid: 'app-x',
|
||||
key: 'k',
|
||||
body: '{"n":0}',
|
||||
timeoutMs: 5000,
|
||||
render: () => `{"n":${++renders}}`,
|
||||
};
|
||||
await expect(transport.send(call)).resolves.toMatchObject({
|
||||
status: 200,
|
||||
});
|
||||
expect(bodies).toEqual(['{"n":1}', '{"n":2}']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deploy-on-miss of an evicted script', () => {
|
||||
it('redeploys the evicted script, then resends the run with the deployed marker', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-m': { ownerUserId: 8 } });
|
||||
// The namespace only has the script once the deploy lands: before
|
||||
// that the dispatcher answers a miss.
|
||||
let resident = false;
|
||||
layers.drivers.workers.create = (async (args: {
|
||||
workerName: string;
|
||||
}) => {
|
||||
created.push(args.workerName);
|
||||
resident = true;
|
||||
return { success: true, errors: [] };
|
||||
}) as never;
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
|
||||
const sent: boolean[] = [];
|
||||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
sent.push(headers[EVENTS_DEPLOYED_HEADER] === '1');
|
||||
if (resident)
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { [EVENTS_HANDLED_HEADER]: '1' },
|
||||
});
|
||||
return new Response(null, {
|
||||
status: 404,
|
||||
headers: { [EVENTS_DISPATCH_ERROR_HEADER]: 'missing' },
|
||||
});
|
||||
}) as unknown as typeof undiciFetch;
|
||||
|
||||
const client = new EventsWorkerInvokerClient({
|
||||
events: { workerRuntime: true },
|
||||
} as IConfig);
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl,
|
||||
}),
|
||||
);
|
||||
client.setMissHandler((appUid, script, options) =>
|
||||
deployer.ensure(appHandlerScope(appUid), script, options),
|
||||
);
|
||||
|
||||
const result = await client.invoke({
|
||||
script: SCRIPT,
|
||||
appUid: 'app-m',
|
||||
handler: 'h',
|
||||
token: 't',
|
||||
key: 'k',
|
||||
event: {},
|
||||
ctx: {},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ outcome: 'settled', status: 200 });
|
||||
expect(created).toEqual([SCRIPT]);
|
||||
// One miss, then the resend carrying the deployed marker.
|
||||
expect(sent).toEqual([false, true]);
|
||||
});
|
||||
});
|
||||
|
||||
// The redis mock is shared across tests, so each one uses its own app and code.
|
||||
let fresh = 0;
|
||||
const nextApp = () => `app-u${++fresh}`;
|
||||
const userScope = (holder: number, appUid: string) => ({ appUid, holder });
|
||||
const sourceN = (label: string) =>
|
||||
`async () => { await ${label}${++fresh}(); }`;
|
||||
const scriptOf = (name: string, source: string) =>
|
||||
userHandlerScript({ name, sourceHash: hashContent(source) }, '');
|
||||
|
||||
type Sets = Record<number, Array<{ name: string; source: string }>>;
|
||||
|
||||
/** Layers whose handler sets are per holder (0 is the app's own). */
|
||||
const withSets = (layers: Layers, sets: Sets) => {
|
||||
const handlersOf = (scope: { appUid: string; holder: number }) =>
|
||||
(sets[scope.holder] ?? []).map((h) => ({
|
||||
appUid: scope.appUid,
|
||||
name: h.name,
|
||||
source: h.source,
|
||||
sourceHash: hashContent(h.source),
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
}));
|
||||
const store = layers.stores.eventHandler as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
store.setForApp = async (scope: { appUid: string; holder: number }) =>
|
||||
handlersOf(scope).map(({ name, sourceHash }) => ({ name, sourceHash }));
|
||||
store.allForApp = async (scope: { appUid: string; holder: number }) =>
|
||||
handlersOf(scope);
|
||||
};
|
||||
|
||||
const holders = (...ids: number[]) =>
|
||||
Object.fromEntries(
|
||||
ids.map((id) => [`holder-${id}`, { ownerUserId: id }]),
|
||||
) as Record<string, Owner>;
|
||||
|
||||
describe('EventsWorkerDeployer in a user scope', () => {
|
||||
it('deploys the one handler whose script was asked for, as the holder', async () => {
|
||||
const app = nextApp();
|
||||
const a = sourceN('a');
|
||||
const b = sourceN('b');
|
||||
const { layers, created, targets } = makeLayers(holders(10));
|
||||
withSets(layers, {
|
||||
10: [
|
||||
{ name: 'a', source: a },
|
||||
{ name: 'b', source: b },
|
||||
],
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
|
||||
const script = scriptOf('b', b);
|
||||
expect(await deployer.ensure(userScope(10, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
|
||||
expect(created).toEqual([script]);
|
||||
const target = targets[0]!;
|
||||
expect(target.tags).toEqual([`events-app:${app}`, 'user:uuid-10']);
|
||||
// One handler's script: the other one's code is not in it.
|
||||
expect(target.source).toContain(b);
|
||||
expect(target.source).not.toContain(a);
|
||||
});
|
||||
|
||||
it('answers a script no handler of the scope matches as stale, before the throttle', async () => {
|
||||
const app = nextApp();
|
||||
const { layers, redis, created } = makeLayers(holders(10));
|
||||
withSets(layers, { 10: [{ name: 'a', source: sourceN('a') }] });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
|
||||
expect(
|
||||
await deployer.ensure(
|
||||
userScope(10, app),
|
||||
scriptOf('b', sourceN('b')),
|
||||
),
|
||||
).toBe('stale');
|
||||
expect(created).toEqual([]);
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
expect(await redis.get(`ev:deploys:{${app}}:10:${hour}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('answers a scope with no handlers as no-handlers', async () => {
|
||||
const { layers } = makeLayers(holders(10));
|
||||
withSets(layers, {});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
expect(
|
||||
await deployer.ensure(
|
||||
userScope(10, nextApp()),
|
||||
scriptOf('a', sourceN('a')),
|
||||
),
|
||||
).toBe('no-handlers');
|
||||
});
|
||||
|
||||
it('does not deploy for a holder that is suspended or gone', async () => {
|
||||
const source = sourceN('a');
|
||||
const { layers, created } = makeLayers({
|
||||
'holder-10': { ownerUserId: 10, suspended: true },
|
||||
});
|
||||
withSets(layers, {
|
||||
10: [{ name: 'a', source }],
|
||||
11: [{ name: 'a', source }],
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const script = scriptOf('a', source);
|
||||
const app = nextApp();
|
||||
|
||||
expect(await deployer.ensure(userScope(10, app), script)).toBe(
|
||||
'no-owner',
|
||||
);
|
||||
expect(await deployer.ensure(userScope(11, app), script)).toBe(
|
||||
'no-owner',
|
||||
);
|
||||
expect(created).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives each scope its own hourly cap, apart from the app scope', async () => {
|
||||
const app = nextApp();
|
||||
const source = sourceN('a');
|
||||
const { layers, created } = makeLayers({
|
||||
...holders(10, 11),
|
||||
[app]: { ownerUserId: 12 },
|
||||
});
|
||||
withSets(layers, {
|
||||
0: [{ name: 'a', source }],
|
||||
10: [{ name: 'a', source }],
|
||||
11: [{ name: 'a', source }],
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const script = scriptOf('a', source);
|
||||
|
||||
for (let i = 0; i < EVENTS_USER_WORKER_DEPLOYS_PER_HOUR; i++)
|
||||
expect(await deployer.ensure(userScope(10, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(await deployer.ensure(userScope(10, app), script)).toBe(
|
||||
'throttled',
|
||||
);
|
||||
expect(created).toHaveLength(EVENTS_USER_WORKER_DEPLOYS_PER_HOUR);
|
||||
|
||||
// Another holder of the same app, and the app's own scope, still go.
|
||||
expect(await deployer.ensure(userScope(11, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(await deployer.ensure(appHandlerScope(app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
});
|
||||
|
||||
it('collapses concurrent deploys of one script across holders into one upload', async () => {
|
||||
const app = nextApp();
|
||||
const source = sourceN('a');
|
||||
const { layers, created } = makeLayers(holders(10, 11));
|
||||
withSets(layers, {
|
||||
10: [{ name: 'a', source }],
|
||||
11: [{ name: 'a', source }],
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const script = scriptOf('a', source);
|
||||
|
||||
await Promise.all([
|
||||
deployer.ensure(userScope(10, app), script),
|
||||
deployer.ensure(userScope(10, app), script),
|
||||
deployer.ensure(userScope(11, app), script),
|
||||
deployer.ensure(userScope(11, nextApp()), script),
|
||||
]);
|
||||
expect(created).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('names a script for identical code the same under any holder and app', async () => {
|
||||
const source = sourceN('a');
|
||||
const { layers, created } = makeLayers(holders(10, 11));
|
||||
withSets(layers, {
|
||||
10: [{ name: '@x', source }],
|
||||
11: [{ name: '@x', source }],
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const script = scriptOf('@x', source);
|
||||
|
||||
expect(await deployer.ensure(userScope(10, nextApp()), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(await deployer.ensure(userScope(11, nextApp()), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(created).toEqual([script, script]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the deployed marker', () => {
|
||||
const setup = () => {
|
||||
const source = sourceN('a');
|
||||
const made = makeLayers(holders(10, 11));
|
||||
withSets(made.layers, {
|
||||
10: [{ name: 'a', source }],
|
||||
11: [{ name: 'a', source }],
|
||||
});
|
||||
return {
|
||||
...made,
|
||||
deployer: new EventsWorkerDeployer(made.layers),
|
||||
script: scriptOf('a', source),
|
||||
app: nextApp(),
|
||||
};
|
||||
};
|
||||
|
||||
it('is left by a user-scope deploy with a ttl under a day', async () => {
|
||||
const { deployer, redis, script, app } = setup();
|
||||
await deployer.ensure(userScope(10, app), script);
|
||||
const ttl = await redis.ttl(`ev:deployed:${script}`);
|
||||
expect(ttl).toBeGreaterThan(0);
|
||||
expect(ttl).toBeLessThan(24 * 60 * 60);
|
||||
});
|
||||
|
||||
it('lets an eager deploy skip the upload another holder already made', async () => {
|
||||
const { deployer, created, script, app } = setup();
|
||||
expect(await deployer.deployEagerly(userScope(10, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(await deployer.deployEagerly(userScope(11, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(created).toEqual([script]);
|
||||
});
|
||||
|
||||
it('spends no throttle on a skipped eager deploy', async () => {
|
||||
const { deployer, redis, script, app } = setup();
|
||||
await deployer.deployEagerly(userScope(10, app), script);
|
||||
await deployer.deployEagerly(userScope(11, app), script);
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
expect(await redis.get(`ev:deploys:{${app}}:11:${hour}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('never skips a deploy-on-miss', async () => {
|
||||
const { deployer, created, script, app } = setup();
|
||||
await deployer.deployEagerly(userScope(10, app), script);
|
||||
expect(await deployer.ensure(userScope(11, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(created).toEqual([script, script]);
|
||||
});
|
||||
|
||||
it('is not left by an app-scope deploy', async () => {
|
||||
const app = nextApp();
|
||||
const source = sourceN('a');
|
||||
const { layers, redis, created } = makeLayers({
|
||||
[app]: { ownerUserId: 4 },
|
||||
});
|
||||
withSets(layers, { 0: [{ name: 'a', source }] });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const script = scriptOf('a', source);
|
||||
await deployer.ensure(appHandlerScope(app), script);
|
||||
expect(created).toEqual([script]);
|
||||
expect(await redis.get(`ev:deployed:${script}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('still deploys eagerly when the marker cannot be read', async () => {
|
||||
const { deployer, redis, created, script, app } = setup();
|
||||
await deployer.deployEagerly(userScope(10, app), script);
|
||||
const get = redis.get.bind(redis);
|
||||
redis.get = (async (key: string) => {
|
||||
if (key.startsWith('ev:deployed:')) throw new Error('down');
|
||||
return get(key);
|
||||
}) as never;
|
||||
expect(await deployer.deployEagerly(userScope(11, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(created).toEqual([script, script]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeFor', () => {
|
||||
it('reads no holder as the app scope, and resolves a uuid to the user scope', async () => {
|
||||
const { layers } = makeLayers(holders(10));
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
expect(await deployer.scopeFor('app-u')).toEqual({
|
||||
appUid: 'app-u',
|
||||
holder: 0,
|
||||
});
|
||||
expect(await deployer.scopeFor('app-u', '')).toEqual({
|
||||
appUid: 'app-u',
|
||||
holder: 0,
|
||||
});
|
||||
expect(await deployer.scopeFor('app-u', 'uuid-10')).toEqual({
|
||||
appUid: 'app-u',
|
||||
holder: 10,
|
||||
});
|
||||
expect(await deployer.scopeFor('app-u', 'uuid-99')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LocalEventsInvokeTransport in a user scope', () => {
|
||||
it('deploys the holder`s script on a miss and refuses an unknown holder', async () => {
|
||||
const source = sourceN('a');
|
||||
const { layers, created } = makeLayers(holders(10));
|
||||
withSets(layers, { 10: [{ name: 'a', source }] });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
let resident = false;
|
||||
const transport = new LocalEventsInvokeTransport(
|
||||
{
|
||||
localworkerservice: {
|
||||
dispatchEventsWorker: async () => {
|
||||
if (!resident) return null;
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'x-puter-events-handled': '1' },
|
||||
});
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
ensure: async (
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions,
|
||||
) => {
|
||||
const outcome = await deployer.ensure(
|
||||
scope,
|
||||
script,
|
||||
options,
|
||||
);
|
||||
resident = outcome === 'deployed';
|
||||
return outcome;
|
||||
},
|
||||
scopeFor: (appUid: string, holder?: string) =>
|
||||
deployer.scopeFor(appUid, holder),
|
||||
} as unknown as EventsWorkerDeployer,
|
||||
);
|
||||
const script = scriptOf('a', source);
|
||||
const call: EventsInvokeCall = {
|
||||
script,
|
||||
appUid: nextApp(),
|
||||
holder: 'uuid-10',
|
||||
key: 'k',
|
||||
body: '{}',
|
||||
timeoutMs: 5000,
|
||||
};
|
||||
|
||||
await expect(transport.send(call)).resolves.toMatchObject({
|
||||
status: 200,
|
||||
});
|
||||
expect(created).toEqual([script]);
|
||||
|
||||
resident = false;
|
||||
await expect(
|
||||
transport.send({ ...call, holder: 'uuid-99' }),
|
||||
).resolves.toMatchObject({ status: null, error: 'deploy: no-holder' });
|
||||
expect(created).toEqual([script]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent eager deploys of one script', () => {
|
||||
const setup = () => {
|
||||
const source = sourceN('a');
|
||||
const made = makeLayers(holders(10, 11));
|
||||
withSets(made.layers, {
|
||||
10: [{ name: 'a', source }],
|
||||
11: [{ name: 'a', source }],
|
||||
});
|
||||
return {
|
||||
...made,
|
||||
script: scriptOf('a', source),
|
||||
app: nextApp(),
|
||||
};
|
||||
};
|
||||
|
||||
it('upload once when two holders publish the same new code together', async () => {
|
||||
const { layers, created, script, app } = setup();
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
|
||||
const outcomes = await Promise.all([
|
||||
deployer.deployEagerly(userScope(10, app), script),
|
||||
deployer.deployEagerly(userScope(11, app), script),
|
||||
]);
|
||||
|
||||
expect(outcomes).toEqual(['deployed', 'deployed']);
|
||||
expect(created).toEqual([script]);
|
||||
});
|
||||
|
||||
it('skip the upload when the marker appears while they wait on the gate', async () => {
|
||||
const { layers, redis, created, script, app } = setup();
|
||||
// Another node's deploy of the script finishes before the gate opens.
|
||||
const gated = new EventsWorkerDeployer({
|
||||
...layers,
|
||||
services: {
|
||||
schedules: {
|
||||
gateDeploy: async (
|
||||
_script: string,
|
||||
_options: { capped: boolean },
|
||||
deploy: () => Promise<EventsDeployOutcome>,
|
||||
) => {
|
||||
await redis.set(`ev:deployed:${script}`, '1');
|
||||
return deploy();
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Layers);
|
||||
|
||||
expect(
|
||||
await gated.ensure(userScope(10, app), script, { eager: true }),
|
||||
).toBe('deployed');
|
||||
expect(created).toEqual([]);
|
||||
// A miss still uploads, whatever the marker says.
|
||||
expect(await gated.ensure(userScope(11, app), script)).toBe('deployed');
|
||||
expect(created).toEqual([script]);
|
||||
});
|
||||
|
||||
it('make a miss that joins an eager deploy deploy after it', async () => {
|
||||
const { layers, redis, created, script, app } = setup();
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
await redis.set(`ev:deployed:${script}`, '1');
|
||||
|
||||
const eager = deployer.ensure(userScope(10, app), script, {
|
||||
eager: true,
|
||||
});
|
||||
const miss = deployer.ensure(userScope(11, app), script);
|
||||
|
||||
expect(await eager).toBe('deployed');
|
||||
expect(await miss).toBe('deployed');
|
||||
// The eager one skipped on the marker; the miss uploaded.
|
||||
expect(created).toEqual([script]);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import type {
|
||||
EventsInvokeAnswer,
|
||||
EventsInvokeCall,
|
||||
InvokeLane,
|
||||
} from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import {
|
||||
EVENTS_INVOKE_PATH,
|
||||
@@ -36,47 +37,89 @@ import {
|
||||
failedAnswer,
|
||||
} from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import type { puterServices } from '../../services/index.js';
|
||||
import type { IExtensionServiceInstances } from '../../services/types.js';
|
||||
import {
|
||||
eventsInvokeKey,
|
||||
eventsWorkerScope,
|
||||
eventsWorkerScript,
|
||||
userHandlerScript,
|
||||
} from '../../services/events/workerRuntime.js';
|
||||
import {
|
||||
generateEventsWorkerSource,
|
||||
handlerSetHash,
|
||||
} from '../../services/events/workerSource.js';
|
||||
import {
|
||||
appHandlerScope,
|
||||
type HandlerScope,
|
||||
} from '../../stores/events/EventHandlerStore.js';
|
||||
import type { puterStores } from '../../stores/index.js';
|
||||
import type { IConfig, LayerInstances } from '../../types.js';
|
||||
import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
|
||||
import {
|
||||
EVENTS_USER_WORKER_DEPLOYS_PER_HOUR,
|
||||
EVENTS_WORKER_DEPLOYS_PER_HOUR,
|
||||
} from './limits.js';
|
||||
|
||||
/**
|
||||
* Deploys an app's events worker from its published handlers.
|
||||
*
|
||||
* Deployed the first time a delivery needs it and again on eviction — a publish
|
||||
* only writes rows. Scripts carry no worker token and no `subdomains` row: not
|
||||
* one of the owner's workers, not addressable from the internet, not listed or
|
||||
* deleted as if it were. A republished set leaves the old script behind rather
|
||||
* than deleting it; redeploying a still-live one is always safe.
|
||||
* only writes rows (a user-scope publish also deploys eagerly). An app's scope
|
||||
* is one script for the whole set; a user scope is one script per handler,
|
||||
* deployed as the holder. Scripts carry no worker token and no `subdomains`
|
||||
* row: not one of the owner's workers, not addressable from the internet, not
|
||||
* listed or deleted as if it were. A republished set leaves the old script
|
||||
* behind rather than deleting it; redeploying a still-live one is always safe.
|
||||
*/
|
||||
|
||||
/** Why a deploy could not happen, for the callers that answer differently. */
|
||||
export type EventsDeployOutcome =
|
||||
'deployed' | 'stale' | 'no-handlers' | 'no-owner' | 'throttled' | 'failed';
|
||||
| 'deployed'
|
||||
| 'stale'
|
||||
| 'no-handlers'
|
||||
| 'no-owner'
|
||||
| 'throttled'
|
||||
| 'failed'
|
||||
| 'busy';
|
||||
|
||||
/** Whose call asked for the deploy: a schedule run's is held to a share. */
|
||||
export interface EventsDeployOptions {
|
||||
lane?: InvokeLane;
|
||||
/**
|
||||
* A user-scope publish's deploy, which skips the upload when the script's
|
||||
* deployed marker is already set. A deploy-on-miss is never eager.
|
||||
*/
|
||||
eager?: boolean;
|
||||
}
|
||||
|
||||
interface DeployLayers {
|
||||
config: IConfig;
|
||||
clients: LayerInstances<typeof puterClients>;
|
||||
stores: LayerInstances<typeof puterStores>;
|
||||
services: LayerInstances<typeof puterServices>;
|
||||
services: LayerInstances<typeof puterServices> &
|
||||
Pick<IExtensionServiceInstances, 'schedules'>;
|
||||
drivers: LayerInstances<typeof puterDrivers>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ev:deploys:{<appUid>}:<hour>` bucket TTL — comfortably past the hour it
|
||||
* counts.
|
||||
* `ev:deploys:{<appUid>}[:<holder>]:<hour>` bucket TTL — comfortably past the
|
||||
* hour it counts.
|
||||
*/
|
||||
const DEPLOY_THROTTLE_TTL_SECONDS = 2 * 60 * 60;
|
||||
|
||||
/** How long `ev:deployed:<script>` says a script was uploaded. Under a day. */
|
||||
const DEPLOYED_MARKER_TTL_SECONDS = 20 * 60 * 60;
|
||||
|
||||
const deployedMarkerKey = (script: string): string => `ev:deployed:${script}`;
|
||||
|
||||
const isUserScope = (scope: HandlerScope): boolean => scope.holder !== 0;
|
||||
|
||||
interface InFlightDeploy {
|
||||
/** A schedule run's, which the region's deploy slots may refuse. */
|
||||
capped: boolean;
|
||||
eager: boolean;
|
||||
promise: Promise<EventsDeployOutcome>;
|
||||
}
|
||||
|
||||
interface DeployResult {
|
||||
success?: boolean;
|
||||
errors?: unknown[];
|
||||
@@ -85,7 +128,7 @@ interface DeployResult {
|
||||
export class EventsWorkerDeployer {
|
||||
readonly #layers: DeployLayers;
|
||||
/** Collapses the burst of misses one cold app produces into one deploy. */
|
||||
readonly #inFlight = new Map<string, Promise<EventsDeployOutcome>>();
|
||||
readonly #inFlight = new Map<string, InFlightDeploy>();
|
||||
|
||||
constructor(layers: DeployLayers) {
|
||||
this.#layers = layers;
|
||||
@@ -100,44 +143,188 @@ export class EventsWorkerDeployer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy `script`, which must be what the app's handlers currently hash to.
|
||||
* A request for anything else is `stale` — the set moved on, and the next
|
||||
* delivery resolves the new script rather than this one being revived.
|
||||
* Deploy `script`, which must be what the scope's handlers currently hash
|
||||
* to: the whole set for an app, one handler for a user scope. A request for
|
||||
* anything else is `stale` — the handlers moved on, and the next delivery
|
||||
* resolves the new script rather than this one being revived. Always
|
||||
* uploads; a deploy-on-miss means whatever said it was there is wrong.
|
||||
*/
|
||||
ensure(appUid: string, script: string): Promise<EventsDeployOutcome> {
|
||||
const key = `${appUid}|${script}`;
|
||||
ensure(
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions = {},
|
||||
): Promise<EventsDeployOutcome> {
|
||||
// A user scope's script is the same bytes whoever holds it, so
|
||||
// concurrent deploys of it from any holder or app are one.
|
||||
const key = isUserScope(scope)
|
||||
? `user|${script}`
|
||||
: `${scope.appUid}|${scope.holder}|${script}`;
|
||||
const capped = options.lane === 'schedule';
|
||||
const existing = this.#inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
const started = this.#deploy(appUid, script).finally(() =>
|
||||
this.#inFlight.delete(key),
|
||||
if (!existing) return this.#start(key, scope, script, options);
|
||||
// An eager deploy may have skipped the upload, which a miss must not
|
||||
// take for done.
|
||||
if (existing.eager && !options.eager)
|
||||
return this.#afterEager(
|
||||
existing.promise,
|
||||
key,
|
||||
scope,
|
||||
script,
|
||||
options,
|
||||
);
|
||||
if (capped || !existing.capped) return existing.promise;
|
||||
return this.#afterCapped(existing.promise, key, scope, script, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link ensure} for the deploy a user-scope publish starts, which skips the
|
||||
* upload when another holder's deploy of the same script left its marker.
|
||||
* Only this path reads the marker.
|
||||
*/
|
||||
async deployEagerly(
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
if (await this.#markedDeployed(script)) return 'deployed';
|
||||
return this.ensure(scope, script, { eager: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope a script request names: the app's own, or the user scope of
|
||||
* `holder` (a user uuid). Null for a holder that does not exist.
|
||||
*/
|
||||
async scopeFor(
|
||||
appUid: string,
|
||||
holder?: string | null,
|
||||
): Promise<HandlerScope | null> {
|
||||
if (!holder) return appHandlerScope(appUid);
|
||||
const user = await this.#layers.stores.user.getByUuid(holder);
|
||||
if (!user?.id) return null;
|
||||
return { appUid, holder: Number(user.id) };
|
||||
}
|
||||
|
||||
#start(
|
||||
key: string,
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
const entry: InFlightDeploy = {
|
||||
capped: options.lane === 'schedule',
|
||||
eager: options.eager === true,
|
||||
promise: this.#gated(scope, script, options).finally(() => {
|
||||
if (this.#inFlight.get(key) === entry)
|
||||
this.#inFlight.delete(key);
|
||||
}),
|
||||
};
|
||||
this.#inFlight.set(key, entry);
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
/** A miss that found an eager deploy in flight deploys after it. */
|
||||
async #afterEager(
|
||||
eager: Promise<EventsDeployOutcome>,
|
||||
key: string,
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
await eager;
|
||||
const current = this.#inFlight.get(key);
|
||||
if (current && !current.eager) return current.promise;
|
||||
return this.#start(key, scope, script, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* A delivery that joined a schedule run's deploy: `busy` means no deploy
|
||||
* started, and a delivery is never refused a slot, so it asks again.
|
||||
*/
|
||||
async #afterCapped(
|
||||
capped: Promise<EventsDeployOutcome>,
|
||||
key: string,
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
const outcome = await capped;
|
||||
if (outcome !== 'busy') return outcome;
|
||||
const current = this.#inFlight.get(key);
|
||||
if (current && !current.capped) return current.promise;
|
||||
return this.#start(key, scope, script, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every deploy goes through the schedules' deploy gate when a server has
|
||||
* one: a per-script lock, so a burst of runs of an evicted script deploys
|
||||
* it once, and a region-wide cap that a schedule run's deploy (`capped`)
|
||||
* may be refused with `busy`. A delivery's deploy is never refused.
|
||||
*/
|
||||
#gated(
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
const deploy = () =>
|
||||
this.#deploy(scope, script, options.eager === true);
|
||||
return (
|
||||
this.#layers.services.schedules?.gateDeploy?.(
|
||||
script,
|
||||
{ capped: options.lane === 'schedule' },
|
||||
deploy,
|
||||
) ?? deploy()
|
||||
);
|
||||
this.#inFlight.set(key, started);
|
||||
return started;
|
||||
}
|
||||
|
||||
async #deploy(
|
||||
appUid: string,
|
||||
handlerScope: HandlerScope,
|
||||
script: string,
|
||||
eager: boolean,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
const { appUid } = handlerScope;
|
||||
const { stores, drivers } = this.#layers;
|
||||
const scope = eventsWorkerScope(this.#layers.config);
|
||||
const backend = eventsWorkerScope(this.#layers.config);
|
||||
const userScope = isUserScope(handlerScope);
|
||||
|
||||
// Names and hashes are enough to tell a stale request, so the set's
|
||||
// sources are read and built only once the throttle has let it through.
|
||||
const set = await stores.eventHandler.setForApp(appUid);
|
||||
const set = await stores.eventHandler.setForApp(handlerScope);
|
||||
if (set.length === 0) return 'no-handlers';
|
||||
// Two apps that published byte-identical sets share one script, which
|
||||
// is sound: the source is the same, and everything per-app arrives with
|
||||
// the invocation. Whoever deploys first owns it.
|
||||
if (eventsWorkerScript(handlerSetHash(set), scope) !== script)
|
||||
// the invocation. Whoever deploys first owns it. A user scope's script
|
||||
// is one handler's, so it is shared the same way across holders.
|
||||
const wanted = userScope
|
||||
? set.find((entry) => userHandlerScript(entry, backend) === script)
|
||||
: undefined;
|
||||
if (
|
||||
userScope
|
||||
? !wanted
|
||||
: eventsWorkerScript(handlerSetHash(set), backend) !== script
|
||||
)
|
||||
return 'stale';
|
||||
|
||||
if (await this.#tooManyDeploysThisHour(appUid)) return 'throttled';
|
||||
// Asked again inside the gate, where another deploy of this script may
|
||||
// have just finished.
|
||||
if (eager && userScope && (await this.#markedDeployed(script)))
|
||||
return 'deployed';
|
||||
|
||||
const handlers = await stores.eventHandler.allForApp(appUid);
|
||||
if (await this.#tooManyDeploysThisHour(handlerScope))
|
||||
return 'throttled';
|
||||
|
||||
const all = await stores.eventHandler.allForApp(handlerScope);
|
||||
const handlers = wanted
|
||||
? all.filter(
|
||||
(handler) =>
|
||||
handler.name === wanted.name &&
|
||||
handler.sourceHash === wanted.sourceHash,
|
||||
)
|
||||
: all;
|
||||
const generated = generateEventsWorkerSource(handlers);
|
||||
// The set can move between the two reads.
|
||||
if (eventsWorkerScript(generated.setHash, scope) !== script)
|
||||
if (
|
||||
handlers.length === 0 ||
|
||||
eventsWorkerScript(generated.setHash, backend) !== script
|
||||
)
|
||||
return 'stale';
|
||||
if (generated.tooLarge) {
|
||||
console.error(
|
||||
@@ -151,7 +338,7 @@ export class EventsWorkerDeployer {
|
||||
generated.broken.join(', '),
|
||||
);
|
||||
|
||||
const owner = await this.#owner(appUid);
|
||||
const owner = await this.#owner(handlerScope);
|
||||
if (!owner) return 'no-owner';
|
||||
|
||||
const secret = this.#layers.config.events?.internalSecret;
|
||||
@@ -180,6 +367,7 @@ export class EventsWorkerDeployer {
|
||||
runtime: 'events',
|
||||
source: generated.source,
|
||||
omitOwnerToken: true,
|
||||
cpuMs: this.#layers.config.events?.workerCpuMs,
|
||||
// What the script compares an invocation against.
|
||||
// Derived from the script name, so a redeploy of
|
||||
// the same set binds the same key.
|
||||
@@ -210,34 +398,74 @@ export class EventsWorkerDeployer {
|
||||
);
|
||||
return 'failed';
|
||||
}
|
||||
if (userScope) await this.#markDeployed(script);
|
||||
return 'deployed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this app has already deployed `EVENTS_WORKER_DEPLOYS_PER_HOUR`
|
||||
* times this hour. A flapping set (publish, remove, republish) provisions
|
||||
* upstream on every miss, and this is the ceiling on that loop.
|
||||
*/
|
||||
async #tooManyDeploysThisHour(appUid: string): Promise<boolean> {
|
||||
const redis = this.#layers.clients.redis;
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
const key = `ev:deploys:{${appUid}}:${hour}`;
|
||||
const count = await redis.incr(key);
|
||||
if (count === 1) await redis.expire(key, DEPLOY_THROTTLE_TTL_SECONDS);
|
||||
return count > EVENTS_WORKER_DEPLOYS_PER_HOUR;
|
||||
async #markedDeployed(script: string): Promise<boolean> {
|
||||
try {
|
||||
return !!(await this.#layers.clients.redis.get(
|
||||
deployedMarkerKey(script),
|
||||
));
|
||||
} catch (err) {
|
||||
console.warn('[events] deployed marker unreadable', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The app owner, as an actor the deploy can run as. */
|
||||
async #markDeployed(script: string): Promise<void> {
|
||||
try {
|
||||
await this.#layers.clients.redis.set(
|
||||
deployedMarkerKey(script),
|
||||
'1',
|
||||
'EX',
|
||||
DEPLOYED_MARKER_TTL_SECONDS,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[events] could not mark a script deployed', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this scope has already deployed its hourly cap this hour. A
|
||||
* flapping set (publish, remove, republish) provisions upstream on every
|
||||
* miss, and this is the ceiling on that loop. An app's counter is per app;
|
||||
* a user scope has its own, so one user cannot spend another's.
|
||||
*/
|
||||
async #tooManyDeploysThisHour(scope: HandlerScope): Promise<boolean> {
|
||||
const redis = this.#layers.clients.redis;
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
const userScope = isUserScope(scope);
|
||||
const key = userScope
|
||||
? `ev:deploys:{${scope.appUid}}:${scope.holder}:${hour}`
|
||||
: `ev:deploys:{${scope.appUid}}:${hour}`;
|
||||
const count = await redis.incr(key);
|
||||
if (count === 1) await redis.expire(key, DEPLOY_THROTTLE_TTL_SECONDS);
|
||||
return (
|
||||
count >
|
||||
(userScope
|
||||
? EVENTS_USER_WORKER_DEPLOYS_PER_HOUR
|
||||
: EVENTS_WORKER_DEPLOYS_PER_HOUR)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user a scope's deploy runs as, as an actor: the app's owner, or the
|
||||
* holder of a user scope.
|
||||
*/
|
||||
async #owner(
|
||||
appUid: string,
|
||||
scope: HandlerScope,
|
||||
): Promise<
|
||||
| (Actor & { user: { id: number; uuid: string; username: string } })
|
||||
| null
|
||||
> {
|
||||
const { stores } = this.#layers;
|
||||
const app = await stores.app.getByUid(appUid);
|
||||
const ownerUserId = Number(app?.owner_user_id);
|
||||
if (!app || !Number.isFinite(ownerUserId)) return null;
|
||||
let ownerUserId = scope.holder;
|
||||
if (!isUserScope(scope)) {
|
||||
const app = await stores.app.getByUid(scope.appUid);
|
||||
ownerUserId = Number(app?.owner_user_id);
|
||||
if (!app || !Number.isFinite(ownerUserId)) return null;
|
||||
}
|
||||
|
||||
const user = await stores.user.getById(ownerUserId);
|
||||
if (!user?.id || !user.uuid || !user.username || user.suspended)
|
||||
@@ -299,6 +527,8 @@ export class LocalEventsInvokeTransport {
|
||||
}
|
||||
|
||||
async send(call: EventsInvokeCall): Promise<EventsInvokeAnswer> {
|
||||
// Built per attempt, so a send time stamped into the body is taken
|
||||
// after a cold deploy rather than before it.
|
||||
const request = (): Request =>
|
||||
new Request(
|
||||
`http://${call.script}.workers.puter.localhost${EVENTS_INVOKE_PATH}`,
|
||||
@@ -308,7 +538,7 @@ export class LocalEventsInvokeTransport {
|
||||
'content-type': 'application/json',
|
||||
'x-puter-events-key': call.key,
|
||||
},
|
||||
body: call.body,
|
||||
body: call.render ? call.render() : call.body,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -316,12 +546,20 @@ export class LocalEventsInvokeTransport {
|
||||
const resident = await this.#dispatch(request(), call.timeoutMs);
|
||||
if (resident !== null) return resident;
|
||||
|
||||
const outcome = await this.#deployer.ensure(
|
||||
const scope = await this.#deployer.scopeFor(
|
||||
call.appUid,
|
||||
call.script,
|
||||
call.holder,
|
||||
);
|
||||
if (!scope) return { status: null, error: 'deploy: no-holder' };
|
||||
const outcome = await this.#deployer.ensure(scope, call.script, {
|
||||
lane: call.lane,
|
||||
});
|
||||
if (outcome !== 'deployed')
|
||||
return { status: null, error: `deploy: ${outcome}` };
|
||||
return {
|
||||
status: null,
|
||||
error: `deploy: ${outcome}`,
|
||||
...(outcome === 'busy' ? { deployBusy: true } : {}),
|
||||
};
|
||||
|
||||
const retried = await this.#dispatch(request(), call.timeoutMs);
|
||||
if (retried === null)
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { Actor } from '../../core/actor.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { embedFrameAncestors } from './HomepageController.js';
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -529,3 +530,23 @@ describe('HomepageController GET /sw.js', () => {
|
||||
expect(route?.options.subdomain).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('embedFrameAncestors', () => {
|
||||
const config = {
|
||||
origin: 'https://example.test',
|
||||
domain: 'example.test',
|
||||
api_base_url: 'https://backend.example.test',
|
||||
};
|
||||
|
||||
it('names a sibling subdomain that embeds the browser', () => {
|
||||
expect(
|
||||
embedFrameAncestors(config, 'https://sheets.example.test/doc/1'),
|
||||
).toBe("'self' https://sheets.example.test");
|
||||
});
|
||||
|
||||
it('refuses the configured API host even off the api label', () => {
|
||||
expect(
|
||||
embedFrameAncestors(config, 'https://backend.example.test/x'),
|
||||
).toBe("'self'");
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,11 @@ import path from 'node:path';
|
||||
import { PuterController } from '../types.js';
|
||||
import { toAppShellView } from '../../util/appShellView.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter';
|
||||
import {
|
||||
isReservedSubdomain,
|
||||
subdomainLabelOf,
|
||||
} from '../../core/http/middleware/hostRedirects.js';
|
||||
import type { IConfig } from '../../types';
|
||||
import type {
|
||||
PuterHomepageService,
|
||||
PageMeta,
|
||||
@@ -43,9 +48,9 @@ const APP_LANDING_LIMIT = {
|
||||
};
|
||||
|
||||
/**
|
||||
* App descriptions can run long; search snippets and social cards want a
|
||||
* short blurb. Collapses whitespace, then cuts at a word boundary with an
|
||||
* ellipsis so the result never exceeds `APP_META_DESCRIPTION_MAX`.
|
||||
* App descriptions can run long; search snippets and social cards want a short
|
||||
* blurb. Collapses whitespace, then cuts at a word boundary with an ellipsis so
|
||||
* the result never exceeds `APP_META_DESCRIPTION_MAX`.
|
||||
*/
|
||||
export function appMetaDescription(text: string): string {
|
||||
const clean = text.replace(/\s+/g, ' ').trim();
|
||||
@@ -56,6 +61,53 @@ export function appMetaDescription(text: string): string {
|
||||
return `${cut.replace(/[\s,;:.!?…—-]+$/, '')}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `frame-ancestors` for the app browser embed. A wildcard over `*.<domain>`
|
||||
* would also admit user-hosted sites whenever the hosting domains sit under it,
|
||||
* so the embedding page is named exactly, from the request's referrer. Only a
|
||||
* single label under `<domain>` qualifies, and not one this server answers
|
||||
* itself (the API, WebDAV, SDK and native-app hosts).
|
||||
*/
|
||||
export function embedFrameAncestors(
|
||||
config: Pick<
|
||||
IConfig,
|
||||
| 'origin'
|
||||
| 'domain'
|
||||
| 'api_base_url'
|
||||
| 'static_hosting_domain'
|
||||
| 'static_hosting_domain_alt'
|
||||
| 'private_app_hosting_domain'
|
||||
| 'private_app_hosting_domain_alt'
|
||||
>,
|
||||
referer: string | undefined,
|
||||
): string {
|
||||
const gui = new URL(config.origin ?? `https://${config.domain}`);
|
||||
let parent: URL;
|
||||
try {
|
||||
parent = new URL(referer ?? '');
|
||||
} catch {
|
||||
return "'self'";
|
||||
}
|
||||
const host = parent.hostname.toLowerCase();
|
||||
const label = subdomainLabelOf(host, config.domain);
|
||||
const hostingDomains = [
|
||||
config.static_hosting_domain,
|
||||
config.static_hosting_domain_alt,
|
||||
config.private_app_hosting_domain,
|
||||
config.private_app_hosting_domain_alt,
|
||||
]
|
||||
.map((d) => (d ?? '').toLowerCase().split(':')[0])
|
||||
.filter(Boolean);
|
||||
const allowed =
|
||||
parent.protocol === gui.protocol &&
|
||||
parent.port === gui.port &&
|
||||
label !== undefined &&
|
||||
!isReservedSubdomain(label) &&
|
||||
host !== new URL(config.api_base_url).hostname &&
|
||||
!hostingDomains.some((d) => host === d || host.endsWith(`.${d}`));
|
||||
return allowed ? `'self' ${parent.origin}` : "'self'";
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes that render the Puter GUI shell, plus a catch-all static fallback
|
||||
* under `<gui_assets_root>/src` for non-dist/src paths (images, fonts, lib
|
||||
@@ -105,6 +157,27 @@ export class HomepageController extends PuterController {
|
||||
|
||||
router.get('/', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
router.get('/embed/apps', {}, async (req, res) => {
|
||||
// SDK bootstrap parameters must not change this page's account or API origin.
|
||||
// Checked on the raw URL; `req.query` drops pairs past the parser's limit.
|
||||
if (req.originalUrl.includes('?')) {
|
||||
res.redirect('/embed/apps');
|
||||
return;
|
||||
}
|
||||
res.removeHeader('X-Frame-Options');
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
`frame-ancestors ${embedFrameAncestors(this.config, req.get('referer'))}`,
|
||||
);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
// Shown to visitors without a session; a signed-in embed fetches
|
||||
// its own list, so a failure here only costs signed-out visitors.
|
||||
const guestApps = await this.services.recommendedApps
|
||||
.getRecommendedApps()
|
||||
.catch((): Array<Record<string, unknown>> => []);
|
||||
res.send(homepage.renderAppBrowserEmbed({ guestApps }));
|
||||
});
|
||||
|
||||
router.get('/settings', {}, (req, res) => sendShell(req, res));
|
||||
router.get('/settings/*splat', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
|
||||
@@ -244,7 +244,10 @@ describe('OIDCController GET /auth/oidc/providers', () => {
|
||||
await callRoute('get', '/auth/oidc/providers', makeReq({}), res);
|
||||
// We configured a single `custom` provider; the live service
|
||||
// walks the config and returns its id.
|
||||
expect(captured.body).toEqual({ providers: ['custom'] });
|
||||
expect(captured.body).toEqual({
|
||||
providers: ['custom'],
|
||||
signupOidcOnly: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -396,9 +396,12 @@ export class OIDCController extends PuterController {
|
||||
},
|
||||
},
|
||||
async (_req: Request, res: Response) => {
|
||||
const providers =
|
||||
await this.services.oidc.getEnabledProviderIds();
|
||||
res.json({ providers });
|
||||
const [providers, policy] = await Promise.all([
|
||||
this.services.oidc.getEnabledProviderIds(),
|
||||
this.services.signup.signupPolicy(),
|
||||
]);
|
||||
// The signup window drops its email form when this is set.
|
||||
res.json({ providers, signupOidcOnly: policy.oidcOnly });
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ describe('PuterAIController.openaiResponses parameter forwarding', () => {
|
||||
context_management: [{ type: 'compaction' }],
|
||||
prompt_cache_key: 'ck',
|
||||
prompt_cache_retention: '24h',
|
||||
prompt_cache_options: { ttl: '12h', prewarm: true },
|
||||
store: true,
|
||||
truncation: 'auto',
|
||||
service_tier: 'default',
|
||||
@@ -226,6 +227,9 @@ describe('PuterAIController.openaiResponses parameter forwarding', () => {
|
||||
context_management: [{ type: 'compaction' }],
|
||||
prompt_cache_key: 'ck',
|
||||
prompt_cache_retention: '24h',
|
||||
// Forwarded as sent; the provider keeps only the fields it
|
||||
// accepts.
|
||||
prompt_cache_options: { ttl: '12h', prewarm: true },
|
||||
// forced false regardless of the client's request.
|
||||
store: false,
|
||||
truncation: 'auto',
|
||||
@@ -359,13 +363,18 @@ describe('PuterAIController.openaiResponses input normalization', () => {
|
||||
role: 'user',
|
||||
content: [
|
||||
'a bare string part',
|
||||
{ type: 'input_text', text: 'typed text' },
|
||||
{
|
||||
type: 'input_text',
|
||||
text: 'typed text',
|
||||
prompt_cache_breakpoint: { mode: 'explicit', ttl: '12h' },
|
||||
},
|
||||
{ type: 'output_text', text: 'echoed text' },
|
||||
{
|
||||
type: 'input_image',
|
||||
detail: 'high',
|
||||
image_url: 'https://img.test/a.png',
|
||||
file_id: 'file_img',
|
||||
prompt_cache_breakpoint: { mode: 'explicit' },
|
||||
},
|
||||
{ type: 'input_audio', input_audio: { data: 'AAA' } },
|
||||
{
|
||||
@@ -386,13 +395,18 @@ describe('PuterAIController.openaiResponses input normalization', () => {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'a bare string part' },
|
||||
{ type: 'text', text: 'typed text' },
|
||||
{
|
||||
type: 'text',
|
||||
text: 'typed text',
|
||||
prompt_cache_breakpoint: { mode: 'explicit', ttl: '12h' },
|
||||
},
|
||||
{ type: 'text', text: 'echoed text' },
|
||||
{
|
||||
type: 'image_url',
|
||||
detail: 'high',
|
||||
image_url: { url: 'https://img.test/a.png' },
|
||||
file_id: 'file_img',
|
||||
prompt_cache_breakpoint: { mode: 'explicit' },
|
||||
},
|
||||
{ type: 'input_audio', input_audio: { data: 'AAA' } },
|
||||
{
|
||||
@@ -639,6 +653,33 @@ describe('PuterAIController.openaiResponses output items', () => {
|
||||
// -- /openai/v1/chat/completions edges -------------------------------
|
||||
|
||||
describe('PuterAIController.openaiChatCompletions translation edges', () => {
|
||||
it('forwards the prompt-cache fields to the driver', async () => {
|
||||
const completeSpy = stubChatComplete({
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
});
|
||||
|
||||
const { res } = makeRes();
|
||||
await controller.openaiChatCompletions(
|
||||
makeReq({
|
||||
body: {
|
||||
model: 'gpt-test',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
prompt_cache_key: 'ck',
|
||||
prompt_cache_retention: 'in_memory',
|
||||
prompt_cache_options: { ttl: '30m' },
|
||||
},
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
const args = completeSpy.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(args).toMatchObject({
|
||||
prompt_cache_key: 'ck',
|
||||
prompt_cache_retention: 'in_memory',
|
||||
prompt_cache_options: { ttl: '30m' },
|
||||
});
|
||||
});
|
||||
|
||||
it('derives tool_calls from tool_use content blocks when the message has none', async () => {
|
||||
stubChatComplete({
|
||||
message: {
|
||||
|
||||
@@ -447,6 +447,7 @@ export class PuterAIController extends PuterController {
|
||||
body.reasoning_effort as ICompleteArguments['reasoning_effort'],
|
||||
}
|
||||
: {}),
|
||||
...promptCacheArgsFromBody(body),
|
||||
...openaiCompatProvider(body),
|
||||
...(body.fallback === false ? { fallback: false } : {}),
|
||||
};
|
||||
@@ -842,15 +843,7 @@ export class PuterAIController extends PuterController {
|
||||
body.compaction as ICompleteArguments['compaction'],
|
||||
}
|
||||
: {}),
|
||||
...(body.prompt_cache_key
|
||||
? { prompt_cache_key: String(body.prompt_cache_key) }
|
||||
: {}),
|
||||
...(body.prompt_cache_retention
|
||||
? {
|
||||
prompt_cache_retention:
|
||||
body.prompt_cache_retention as ICompleteArguments['prompt_cache_retention'],
|
||||
}
|
||||
: {}),
|
||||
...promptCacheArgsFromBody(body),
|
||||
// forced off regardless of what the caller sent — OpenAI
|
||||
// defaults `store` to true, which would persist state server-side
|
||||
// on OpenAI's end that this route gives no way to retrieve.
|
||||
@@ -1343,6 +1336,33 @@ const openaiCompatProvider = (
|
||||
return { provider: DEFAULTS.openaiChat };
|
||||
};
|
||||
|
||||
/**
|
||||
* The prompt-cache fields both OpenAI-shaped routes forward as sent; the
|
||||
* provider checks the values.
|
||||
*/
|
||||
const promptCacheArgsFromBody = (
|
||||
body: Record<string, unknown>,
|
||||
): Pick<
|
||||
ICompleteArguments,
|
||||
'prompt_cache_key' | 'prompt_cache_retention' | 'prompt_cache_options'
|
||||
> => ({
|
||||
...(body.prompt_cache_key
|
||||
? { prompt_cache_key: String(body.prompt_cache_key) }
|
||||
: {}),
|
||||
...(body.prompt_cache_retention
|
||||
? {
|
||||
prompt_cache_retention:
|
||||
body.prompt_cache_retention as ICompleteArguments['prompt_cache_retention'],
|
||||
}
|
||||
: {}),
|
||||
...(body.prompt_cache_options
|
||||
? {
|
||||
prompt_cache_options:
|
||||
body.prompt_cache_options as ICompleteArguments['prompt_cache_options'],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const randomId = (): string => crypto.randomUUID().replace(/-/g, '');
|
||||
const generateId = (prefix: string): string => `${prefix}_${randomId()}`;
|
||||
|
||||
@@ -1590,12 +1610,24 @@ const parseJsonMaybe = (value: unknown): unknown => {
|
||||
}
|
||||
};
|
||||
|
||||
/** An OpenAI `prompt_cache_breakpoint` marker, kept on the translated part. */
|
||||
const cacheBreakpointOf = (
|
||||
p: Record<string, unknown>,
|
||||
): { prompt_cache_breakpoint?: unknown } =>
|
||||
p.prompt_cache_breakpoint && typeof p.prompt_cache_breakpoint === 'object'
|
||||
? { prompt_cache_breakpoint: p.prompt_cache_breakpoint }
|
||||
: {};
|
||||
|
||||
const normalizeContentPart = (part: unknown): Record<string, unknown> => {
|
||||
if (typeof part === 'string') return { type: 'text', text: part };
|
||||
if (!part || typeof part !== 'object') return { type: 'text', text: '' };
|
||||
const p = part as Record<string, unknown>;
|
||||
if (p.type === 'input_text' || p.type === 'output_text') {
|
||||
return { type: 'text', text: String(p.text ?? '') };
|
||||
return {
|
||||
type: 'text',
|
||||
text: String(p.text ?? ''),
|
||||
...cacheBreakpointOf(p),
|
||||
};
|
||||
}
|
||||
if (p.type === 'input_image') {
|
||||
return {
|
||||
@@ -1603,6 +1635,7 @@ const normalizeContentPart = (part: unknown): Record<string, unknown> => {
|
||||
...(p.detail ? { detail: p.detail } : {}),
|
||||
...(p.image_url ? { image_url: { url: p.image_url } } : {}),
|
||||
...(p.file_id ? { file_id: p.file_id } : {}),
|
||||
...cacheBreakpointOf(p),
|
||||
};
|
||||
}
|
||||
if (p.type === 'input_audio')
|
||||
@@ -1614,6 +1647,7 @@ const normalizeContentPart = (part: unknown): Record<string, unknown> => {
|
||||
...(p.file_id ? { file_id: p.file_id } : {}),
|
||||
...(p.file_url ? { file_url: p.file_url } : {}),
|
||||
...(p.filename ? { filename: p.filename } : {}),
|
||||
...cacheBreakpointOf(p),
|
||||
};
|
||||
}
|
||||
return p;
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import path from 'node:path';
|
||||
import type { Request, Response } from 'express';
|
||||
import express, { type Request, type Response } from 'express';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { RouteDescriptor } from '../../core/http/types.js';
|
||||
@@ -111,6 +112,7 @@ const callGetHandler = async (
|
||||
let captured: { filename?: string; root?: string } = {};
|
||||
const req = {} as Request;
|
||||
const res = {
|
||||
setHeader: vi.fn(),
|
||||
sendFile: vi.fn((filename: string, opts: { root: string }) => {
|
||||
captured = { filename, root: opts.root };
|
||||
}),
|
||||
@@ -238,6 +240,48 @@ describe('StaticAssetsController puterjs_root', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── push-sw.js caching ──────────────────────────────────────────────
|
||||
|
||||
/** Serves the route's handler over real HTTP and returns the response. */
|
||||
const fetchThroughExpress = async (router: PuterRouter, routePath: string) => {
|
||||
const route = findRoute(router, 'get', routePath);
|
||||
if (!route) throw new Error(`No GET ${routePath} registered`);
|
||||
const app = express();
|
||||
app.get(routePath, (req, res, next) => route.handler(req, res, next));
|
||||
const listener = app.listen(0);
|
||||
await new Promise((resolve) => listener.once('listening', resolve));
|
||||
try {
|
||||
const { port } = listener.address() as AddressInfo;
|
||||
return await fetch(`http://127.0.0.1:${port}${routePath}`);
|
||||
} finally {
|
||||
listener.close();
|
||||
}
|
||||
};
|
||||
|
||||
describe('StaticAssetsController push-sw.js', () => {
|
||||
it.each([
|
||||
['client_libs_root', 'puter.js/push-sw.js'],
|
||||
['puterjs_root', 'push-sw.js'],
|
||||
] as const)(
|
||||
'lets the CDN keep it for five minutes from %s',
|
||||
async (configKey, file) => {
|
||||
const root = makeTempDir();
|
||||
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
|
||||
writeFileSync(path.join(root, file), '/* worker */');
|
||||
const router = buildController({ [configKey]: root });
|
||||
|
||||
for (const route of ['/push-sw.js', '/v2/push-sw.js']) {
|
||||
const response = await fetchThroughExpress(router, route);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('cache-control')).toBe(
|
||||
'public, max-age=0, s-maxage=300',
|
||||
);
|
||||
expect(await response.text()).toBe('/* worker */');
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── gui_assets_root ─────────────────────────────────────────────────
|
||||
|
||||
describe('StaticAssetsController gui_assets_root', () => {
|
||||
|
||||
@@ -23,6 +23,9 @@ import path from 'node:path';
|
||||
import { PuterController } from '../types.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter';
|
||||
|
||||
// Sites import push-sw.js by URL, so the CDN refetches it within five minutes.
|
||||
const PUSH_WORKER_CACHE_CONTROL = 'public, max-age=0, s-maxage=300';
|
||||
|
||||
/**
|
||||
* Static asset routes.
|
||||
*
|
||||
@@ -58,6 +61,7 @@ export class StaticAssetsController extends PuterController {
|
||||
// `/v2/push-sw.js` is the path older docs gave.
|
||||
for (const route of ['/push-sw.js', '/v2/push-sw.js']) {
|
||||
router.get(route, { subdomain: 'js' }, (_req, res) => {
|
||||
res.setHeader('Cache-Control', PUSH_WORKER_CACHE_CONTROL);
|
||||
res.sendFile('puter.js/push-sw.js', { root });
|
||||
});
|
||||
}
|
||||
@@ -108,6 +112,10 @@ export class StaticAssetsController extends PuterController {
|
||||
});
|
||||
for (const route of ['/push-sw.js', '/v2/push-sw.js']) {
|
||||
router.get(route, { subdomain: 'js' }, (_req, res) => {
|
||||
res.setHeader(
|
||||
'Cache-Control',
|
||||
PUSH_WORKER_CACHE_CONTROL,
|
||||
);
|
||||
res.sendFile('push-sw.js', { root: puterjsRoot });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,15 +27,7 @@ import { HttpError, isHttpError } from '../HttpError';
|
||||
import { isLocalWorkerHost } from './localWorkerProxy';
|
||||
|
||||
/** Native-app subdomains served via `nativeAppStatic`. */
|
||||
const NATIVE_APP_SUBDOMAINS = [
|
||||
'about',
|
||||
'developer',
|
||||
'docs',
|
||||
'editor',
|
||||
'markus',
|
||||
'pdf',
|
||||
'apps',
|
||||
] as const;
|
||||
const NATIVE_APP_SUBDOMAINS = ['about', 'developer', 'docs', 'apps'] as const;
|
||||
|
||||
/** Subset served out of a `dist/` subdirectory rather than the app root. */
|
||||
const NATIVE_APPS_WITH_DIST = new Set(['docs', 'developer']);
|
||||
@@ -111,6 +103,10 @@ export const subdomainLabelOf = (
|
||||
return label && !label.includes('.') ? label : undefined;
|
||||
};
|
||||
|
||||
/** Whether core itself answers `<name>.<domain>` (`www` redirects to the root). */
|
||||
export const isReservedSubdomain = (name: string): boolean =>
|
||||
name === 'www' || RESERVED_SUBDOMAINS.has(name);
|
||||
|
||||
/**
|
||||
* Record a claim on `<name>.<domain>`. Throws on a malformed or uppercase
|
||||
* label, a subdomain core already handles, or a name that is already claimed.
|
||||
@@ -123,7 +119,7 @@ export const addClaimedSubdomain = (
|
||||
if (typeof name !== 'string' || !SUBDOMAIN_LABEL.test(name)) {
|
||||
throw new Error(`Invalid subdomain label: ${JSON.stringify(name)}`);
|
||||
}
|
||||
if (name === 'www' || RESERVED_SUBDOMAINS.has(name)) {
|
||||
if (isReservedSubdomain(name)) {
|
||||
throw new Error(`Subdomain '${name}' is reserved`);
|
||||
}
|
||||
if (claims.has(name)) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isHttpError } from './http/HttpError.js';
|
||||
import {
|
||||
SCHEDULE_LIST_LIMIT_CAP,
|
||||
SCHEDULE_PEER_PATH,
|
||||
schedulesUnavailable,
|
||||
} from './schedules.js';
|
||||
|
||||
describe('schedules contract', () => {
|
||||
it('answers an absent schedules service with a 503 events_disabled', () => {
|
||||
const err = schedulesUnavailable();
|
||||
expect(isHttpError(err)).toBe(true);
|
||||
expect(err.statusCode).toBe(503);
|
||||
expect(err.legacyCode).toBe('events_disabled');
|
||||
// Expected on a server without schedules, so it must not page.
|
||||
expect(err.noAlarm).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the list cap and the peer path the extension relies on', () => {
|
||||
expect(SCHEDULE_LIST_LIMIT_CAP).toBe(200);
|
||||
expect(SCHEDULE_PEER_PATH).toBe('/broadcast/events/schedules');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What core asks of schedules. Nothing here is an implementation: an extension
|
||||
* registers `services.schedules`, and a server without one answers every
|
||||
* schedule route and `notify({ sendAt })` with `events_disabled`. Called by
|
||||
* extensions; keep these signatures stable.
|
||||
*/
|
||||
|
||||
import type { EventsDeployOutcome } from '../controllers/events/workerDeploy.js';
|
||||
import type { AppMessage } from '../services/notification/appMessage.js';
|
||||
import type { SuspendedReason } from '../stores/events/DurableSubscriptionStore.js';
|
||||
import type { DurableSubscription } from '../stores/events/types.js';
|
||||
import type { PageResult } from '../util/pagination.js';
|
||||
import type { Actor } from './actor.js';
|
||||
import { HttpError } from './http/HttpError.js';
|
||||
|
||||
/** Why a schedule ended. */
|
||||
export type ScheduleEndReason =
|
||||
| 'removed'
|
||||
| 'ended'
|
||||
| 'app_deleted'
|
||||
| 'uninstalled'
|
||||
| 'permission_revoked'
|
||||
| 'holder_gone'
|
||||
| 'app_gone'
|
||||
| 'orphaned'
|
||||
| 'delivered'
|
||||
| 'dropped';
|
||||
|
||||
/** What happened to a set of subscription rows schedules own. */
|
||||
export type ScheduleSubscriptionChange =
|
||||
| {
|
||||
type: 'suspended';
|
||||
reason: SuspendedReason;
|
||||
/** For a reason that never lifts; defaults to that reason. */
|
||||
endReason?: ScheduleEndReason;
|
||||
}
|
||||
| { type: 'resumed' }
|
||||
| { type: 'ended'; endReason: ScheduleEndReason };
|
||||
|
||||
/** A schedule that keeps running, one that runs once, or a delayed notification. */
|
||||
export type ScheduleViewKind = 'recurring' | 'once' | 'deferred';
|
||||
|
||||
/**
|
||||
* A schedule as its holder sees it. Times are epoch ms. `context` values are
|
||||
* never returned, as for a persistent subscription: only its keys and a hash.
|
||||
*/
|
||||
export interface ScheduleView {
|
||||
scheduleId: string;
|
||||
kind: ScheduleViewKind;
|
||||
appUid: string;
|
||||
/** `null` once ended: the handler binding goes with the schedule. */
|
||||
handlerName: string | null;
|
||||
/** Set only when the handler lives in the holder's own scope. */
|
||||
handlerScope?: 'user';
|
||||
contextKeys: string[] | null;
|
||||
contextHash: string | null;
|
||||
/** Interval of an `every` schedule. */
|
||||
every: number | null;
|
||||
cron: string | null;
|
||||
tz: string;
|
||||
/** First allowed run; a one-shot's only run. */
|
||||
startAt: number;
|
||||
/** Last allowed run, inclusive. */
|
||||
endsAt: number | null;
|
||||
/** `sending` once a delayed send has started; it can't be cancelled then. */
|
||||
state: 'active' | 'suspended' | 'sending' | 'ended';
|
||||
suspendedReason: string | null;
|
||||
endReason: string | null;
|
||||
endedAt: number | null;
|
||||
/**
|
||||
* The slot it runs for next; `null` while suspended or sending, and once
|
||||
* ended.
|
||||
*/
|
||||
nextRunAt: number | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ScheduleListRequest {
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
includeTotal: boolean;
|
||||
}
|
||||
|
||||
/** The caps a holder's schedules are held to, from their plan. */
|
||||
export interface ScheduleQuota {
|
||||
recurringPerUser: number;
|
||||
recurringPerApp: number;
|
||||
oneShotsPerUser: number;
|
||||
oneShotsPerApp: number;
|
||||
/** Worst-case runs per hour × 1000, summed over recurring rows. */
|
||||
budgetPerUser: number;
|
||||
budgetPerApp: number;
|
||||
}
|
||||
|
||||
/** A caller cleared to supply `sendAt`, and when it asked for. */
|
||||
export interface DeferredSendPlan {
|
||||
sendAt: number;
|
||||
/** Due within 5 seconds, or past: sent now rather than stored. */
|
||||
immediate: boolean;
|
||||
holderUserId: number;
|
||||
appUid: string;
|
||||
handlerDepth: number;
|
||||
quota: ScheduleQuota;
|
||||
}
|
||||
|
||||
/** What a delayed app message stores until it is sent. */
|
||||
export interface DeferredAppMessage {
|
||||
message: AppMessage;
|
||||
/** A KV share handle naming the recipient. */
|
||||
to?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A condition on one row of another table that statements embed as `EXISTS
|
||||
* (SELECT 1 FROM <table> WHERE <where>)`, binding `values`.
|
||||
*/
|
||||
export interface SqlGuard {
|
||||
table: string;
|
||||
where: string;
|
||||
values: unknown[];
|
||||
}
|
||||
|
||||
/** Ceiling of a schedule listing page. */
|
||||
export const SCHEDULE_LIST_LIMIT_CAP = 200;
|
||||
|
||||
/** The signed peer route a region's schedule changes are sent to. */
|
||||
export const SCHEDULE_PEER_PATH = '/broadcast/events/schedules';
|
||||
|
||||
export const schedulesUnavailable = (): HttpError =>
|
||||
new HttpError(503, 'Schedules are not available on this server', {
|
||||
legacyCode: 'events_disabled',
|
||||
// The designed answer of a server without schedules, not a fault.
|
||||
noAlarm: true,
|
||||
});
|
||||
|
||||
export interface ScheduleServiceContract {
|
||||
/**
|
||||
* Keep schedules in step with a change to the subscription rows they own.
|
||||
* Never throws: the subscription change has already happened.
|
||||
*/
|
||||
onSubscriptionsChanged(
|
||||
rows: readonly DurableSubscription[],
|
||||
change: ScheduleSubscriptionChange,
|
||||
): Promise<void>;
|
||||
createFor(
|
||||
actor: Actor,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<ScheduleView>;
|
||||
list(
|
||||
actor: Actor,
|
||||
request: ScheduleListRequest,
|
||||
): Promise<PageResult<ScheduleView>>;
|
||||
get(actor: Actor, scheduleId: unknown): Promise<ScheduleView>;
|
||||
update(
|
||||
actor: Actor,
|
||||
scheduleId: unknown,
|
||||
changes: unknown,
|
||||
): Promise<ScheduleView>;
|
||||
remove(actor: Actor, scheduleId: unknown): Promise<void>;
|
||||
/** `results`: each change's index outcome, in order (`null` if dropped). */
|
||||
receivePeerChanges(
|
||||
body: unknown,
|
||||
): Promise<{ applied: number; results?: Array<number | null> }>;
|
||||
/** Checks a caller supplying `sendAt`; `events_disabled` when off. */
|
||||
prepareSendAt(actor: Actor, sendAt: unknown): Promise<DeferredSendPlan>;
|
||||
createDeferredAppMessage(
|
||||
plan: DeferredSendPlan,
|
||||
message: DeferredAppMessage,
|
||||
): Promise<{ scheduleId: string; sendAt: number }>;
|
||||
/**
|
||||
* Runs an events worker deploy under the schedules' deploy gate; `capped`
|
||||
* for a schedule run's. A passthrough when schedules are off.
|
||||
*/
|
||||
gateDeploy?(
|
||||
script: string,
|
||||
options: { capped: boolean },
|
||||
deploy: () => Promise<EventsDeployOutcome>,
|
||||
): Promise<EventsDeployOutcome>;
|
||||
}
|
||||
@@ -449,6 +449,22 @@ describe('AzureResponsesProvider.complete request shape', () => {
|
||||
expect(args.service_tier).toBe('default');
|
||||
});
|
||||
|
||||
it('sends the wire spelling of the in-memory cache retention', async () => {
|
||||
const provider = makeProvider();
|
||||
responsesCreateMock.mockResolvedValueOnce(okResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-5.3-codex',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
prompt_cache_retention: 'in-memory',
|
||||
} as never),
|
||||
);
|
||||
|
||||
const [args] = responsesCreateMock.mock.calls[0]!;
|
||||
expect(args.prompt_cache_retention).toBe('in_memory');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['previous_response_id', 'resp_1'],
|
||||
['conversation', 'conv_1'],
|
||||
|
||||
@@ -35,6 +35,7 @@ import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
|
||||
import {
|
||||
clampReasoningEffort,
|
||||
openAICompatParams,
|
||||
promptCacheRetentionToWire,
|
||||
rejectStatefulResponsesFields,
|
||||
} from '../../utils/openaiParams.js';
|
||||
import { buildCostsOverride } from '../../utils/pricing.js';
|
||||
@@ -209,6 +210,9 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
|
||||
const cacheKey = prompt_cache_key ?? userIdentifier;
|
||||
const cacheRetention = promptCacheRetentionToWire(
|
||||
prompt_cache_retention,
|
||||
);
|
||||
|
||||
// Resolve any `puter_path` content parts into inline base64 data URLs
|
||||
// before the Responses API sees them.
|
||||
@@ -307,8 +311,8 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
...(instructions !== undefined ? { instructions } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}),
|
||||
...(prompt_cache_retention !== undefined
|
||||
? { prompt_cache_retention }
|
||||
...(cacheRetention !== undefined
|
||||
? { prompt_cache_retention: cacheRetention }
|
||||
: {}),
|
||||
...(store !== undefined ? { store } : {}),
|
||||
...(max_tokens !== undefined
|
||||
|
||||
@@ -23,6 +23,7 @@ import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
|
||||
import type { IChatModel, ICompleteArguments } from '../../types.js';
|
||||
import { promptCacheRetentionToWire } from '../../utils/openaiParams.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import {
|
||||
type ChatProviderConfig,
|
||||
@@ -107,12 +108,9 @@ export class MetaProvider extends OpenAICompatProvider {
|
||||
? requestedEffort
|
||||
: undefined;
|
||||
|
||||
// Puter spells the in-memory retention with a hyphen; Meta's enum
|
||||
// uses an underscore.
|
||||
const cacheRetention =
|
||||
args.prompt_cache_retention === 'in-memory'
|
||||
? 'in_memory'
|
||||
: args.prompt_cache_retention;
|
||||
const cacheRetention = promptCacheRetentionToWire(
|
||||
args.prompt_cache_retention,
|
||||
);
|
||||
|
||||
// The identifier is Puter's abuse attribution, so `custom` can't
|
||||
// override it. Cache key defaults to it; see upstreamUserIdentifier.
|
||||
|
||||
+157
-1
@@ -249,6 +249,130 @@ describe('OpenAiChatProvider.complete argument validation', () => {
|
||||
expect(result).toEqual({ delegated: true });
|
||||
});
|
||||
|
||||
it('delegates a 12h cache lifetime to the Responses provider, which is the only API that writes it', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const sibling = {
|
||||
complete: vi.fn().mockResolvedValue({ delegated: true }),
|
||||
};
|
||||
provider.setResponsesProvider(
|
||||
sibling as unknown as Parameters<
|
||||
typeof provider.setResponsesProvider
|
||||
>[0],
|
||||
);
|
||||
|
||||
const params = {
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
prompt_cache_options: { ttl: '12h' as const },
|
||||
};
|
||||
const result = await withTestActor(() => provider.complete(params));
|
||||
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
expect(sibling.complete).toHaveBeenCalledWith(params);
|
||||
expect(result).toEqual({ delegated: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['30m', { mode: 'explicit', ttl: '30m' }],
|
||||
['12h', { mode: 'explicit', ttl: '12h' }],
|
||||
])(
|
||||
'delegates a %s breakpoint lifetime to the Responses provider, since Chat Completions rejects the field',
|
||||
async (_ttl, breakpoint) => {
|
||||
const { provider } = makeProvider();
|
||||
const sibling = {
|
||||
complete: vi.fn().mockResolvedValue({ delegated: true }),
|
||||
};
|
||||
provider.setResponsesProvider(
|
||||
sibling as unknown as Parameters<
|
||||
typeof provider.setResponsesProvider
|
||||
>[0],
|
||||
);
|
||||
|
||||
const params = {
|
||||
model: 'gpt-6-luna',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'long prefix',
|
||||
prompt_cache_breakpoint: breakpoint,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'user', content: 'hi' },
|
||||
],
|
||||
};
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete(params as never),
|
||||
);
|
||||
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
expect(sibling.complete).toHaveBeenCalledWith(params);
|
||||
expect(result).toEqual({ delegated: true });
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a breakpoint without a lifetime on the Chat Completions path, intact', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'long prefix',
|
||||
prompt_cache_breakpoint: { mode: 'explicit' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'user', content: 'hi' },
|
||||
],
|
||||
} as never),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.messages[0]).toMatchObject({
|
||||
role: 'system',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'long prefix',
|
||||
prompt_cache_breakpoint: { mode: 'explicit' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws 400 for a 12h cache lifetime without a Responses sibling provider', async () => {
|
||||
const { provider } = makeProvider();
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
prompt_cache_options: { ttl: '12h' },
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requestPricing delegates to the Responses sibling (web_search\'s own hold), and no-ops without one', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const args = {
|
||||
@@ -349,6 +473,31 @@ describe('OpenAiChatProvider.complete request shape', () => {
|
||||
expect(args.safety_identifier).toBe('puter-u42');
|
||||
});
|
||||
|
||||
it('forwards 30m prompt_cache_options and the wire spelling of prompt_cache_retention', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
prompt_cache_retention: 'in-memory',
|
||||
prompt_cache_options: {
|
||||
mode: 'explicit',
|
||||
ttl: '30m',
|
||||
prewarm: true,
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.prompt_cache_retention).toBe('in_memory');
|
||||
expect(args.prompt_cache_options).toEqual({
|
||||
mode: 'explicit',
|
||||
ttl: '30m',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'gpt-6-astra',
|
||||
'gpt-6.1-sol',
|
||||
@@ -665,7 +814,7 @@ describe('OpenAiChatProvider.complete non-stream output', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
@@ -685,6 +834,13 @@ describe('OpenAiChatProvider.complete non-stream output', () => {
|
||||
cached_tokens: 1000 * Number(luna.costs.cached_tokens),
|
||||
cache_write_tokens: 3000 * Number(luna.costs.cache_write_tokens),
|
||||
});
|
||||
// Chat Completions only writes OpenAI's default 30m entries.
|
||||
expect((result as { usageDetails: unknown }).usageDetails).toEqual({
|
||||
inputTokens: 1000,
|
||||
outputTokens: 12,
|
||||
cacheReadTokens: 1000,
|
||||
cacheWrite30mTokens: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('zeroes cached_tokens when prompt_tokens_details is missing', async () => {
|
||||
|
||||
@@ -35,7 +35,10 @@ import { make_openai_tools } from '../../utils/FunctionCalling.js';
|
||||
import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
|
||||
import {
|
||||
clampReasoningEffort,
|
||||
messagesHaveCacheBreakpointTtl,
|
||||
openAICompatParams,
|
||||
promptCacheOptionsToWire,
|
||||
promptCacheRetentionToWire,
|
||||
} from '../../utils/openaiParams.js';
|
||||
import { OPEN_AI_MODELS } from './models.js';
|
||||
import type { OpenAiResponsesChatProvider } from './OpenAiChatResponsesProvider.js';
|
||||
@@ -126,8 +129,11 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
temperature,
|
||||
text,
|
||||
prompt_cache_key,
|
||||
prompt_cache_retention,
|
||||
prompt_cache_options,
|
||||
} = params;
|
||||
let { messages, model } = params;
|
||||
const cacheOptions = promptCacheOptionsToWire(prompt_cache_options);
|
||||
if (
|
||||
tools?.filter((e) => isWebSearchTool(e as Record<string, unknown>))
|
||||
.length
|
||||
@@ -157,6 +163,21 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
}
|
||||
return await this.#responsesProvider.complete(params);
|
||||
}
|
||||
// Only the Responses API writes 12h cache entries or takes a lifetime
|
||||
// on a `prompt_cache_breakpoint`; Chat Completions rejects both.
|
||||
if (
|
||||
cacheOptions?.ttl === '12h' ||
|
||||
messagesHaveCacheBreakpointTtl(messages)
|
||||
) {
|
||||
if (!this.#responsesProvider) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'a prompt cache lifetime requires the OpenAI Responses provider, which is not configured',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return await this.#responsesProvider.complete(params);
|
||||
}
|
||||
// Validate messages
|
||||
if (!Array.isArray(messages)) {
|
||||
throw new HttpError(400, '`messages` must be an array', {
|
||||
@@ -180,6 +201,9 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
|
||||
const cacheKey = prompt_cache_key ?? userIdentifier;
|
||||
const cacheRetention = promptCacheRetentionToWire(
|
||||
prompt_cache_retention,
|
||||
);
|
||||
|
||||
// Strip Anthropic-only shape a fallback-replayed message can carry
|
||||
// (thinking/server-tool blocks, cache_control, citations) before the
|
||||
@@ -216,6 +240,12 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
user: userIdentifier,
|
||||
safety_identifier: userIdentifier,
|
||||
...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}),
|
||||
...(cacheRetention !== undefined
|
||||
? { prompt_cache_retention: cacheRetention }
|
||||
: {}),
|
||||
...(cacheOptions !== undefined
|
||||
? { prompt_cache_options: cacheOptions }
|
||||
: {}),
|
||||
messages: messages,
|
||||
model: modelUsed.id,
|
||||
...(mappedTools?.length ? { tools: mappedTools } : {}),
|
||||
@@ -254,28 +284,11 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
);
|
||||
|
||||
return OpenAiUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
const cachedTokens =
|
||||
usage.prompt_tokens_details?.cached_tokens ?? 0;
|
||||
// GPT-5.6 and later bill cache writes at 1.25x input. They're
|
||||
// reported inside `prompt_tokens`, like cached reads.
|
||||
// The SDK doesn't type `cache_write_tokens` yet.
|
||||
const cacheWriteTokens =
|
||||
(
|
||||
usage.prompt_tokens_details as
|
||||
{ cache_write_tokens?: number } | undefined
|
||||
)?.cache_write_tokens ?? 0;
|
||||
const trackedUsage = {
|
||||
prompt_tokens:
|
||||
(usage.prompt_tokens ?? 0) -
|
||||
cachedTokens -
|
||||
cacheWriteTokens,
|
||||
completion_tokens: usage.completion_tokens ?? 0,
|
||||
cached_tokens: cachedTokens,
|
||||
...(cacheWriteTokens
|
||||
? { cache_write_tokens: cacheWriteTokens }
|
||||
: {}),
|
||||
};
|
||||
usage_calculator: ({ usage, setUsageCosts, setUsageDetails }) => {
|
||||
// GPT-5.6 and later report cache writes inside
|
||||
// `prompt_tokens`, like cached reads; Chat Completions only
|
||||
// writes the default 30m entries, billed at 1.25x input.
|
||||
const trackedUsage = OpenAiUtil.splitCachedPrompt(usage);
|
||||
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
@@ -285,6 +298,11 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
trackedUsage,
|
||||
);
|
||||
setUsageCosts(metered.costs);
|
||||
setUsageDetails(
|
||||
OpenAiUtil.usageDetailsFromTrackedUsage(metered.usage, {
|
||||
writeTtl: '30m',
|
||||
})!,
|
||||
);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
|
||||
@@ -246,6 +246,7 @@ describe('OpenAiResponsesChatProvider model catalog', () => {
|
||||
prompt_tokens: input,
|
||||
cached_tokens: cached,
|
||||
cache_write_tokens: input * 1.25,
|
||||
cache_write_12h_tokens: input * 2.5,
|
||||
completion_tokens: output,
|
||||
},
|
||||
context: 1_050_000,
|
||||
@@ -844,7 +845,7 @@ describe('OpenAiResponsesChatProvider.complete non-stream output', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
@@ -867,6 +868,115 @@ describe('OpenAiResponsesChatProvider.complete non-stream output', () => {
|
||||
cached_tokens: 1000 * Number(luna.costs.cached_tokens),
|
||||
cache_write_tokens: 3000 * Number(luna.costs.cache_write_tokens),
|
||||
});
|
||||
// Writes without a per-lifetime breakdown are OpenAI's default 30m
|
||||
// entries, and the neutral breakdown says so.
|
||||
expect((result as { usageDetails: unknown }).usageDetails).toEqual({
|
||||
inputTokens: 1000,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 1000,
|
||||
cacheWrite30mTokens: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('bills 12h cache writes at 2.5x input and the other writes at 1.25x', async () => {
|
||||
const luna = OPEN_AI_MODELS.find((m) => m.id === 'gpt-6-luna')!;
|
||||
const { provider } = makeProvider();
|
||||
// One request can write both lifetimes: an explicit 12h breakpoint
|
||||
// plus the implicit 30m one OpenAI adds at the end of the prompt.
|
||||
responsesCreateMock.mockResolvedValueOnce({
|
||||
output: [{ role: 'assistant' }],
|
||||
output_text: 'ok',
|
||||
usage: {
|
||||
input_tokens: 6026,
|
||||
output_tokens: 5,
|
||||
input_tokens_details: {
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 6023,
|
||||
cache_write_tokens_details: {
|
||||
ttl_12h_tokens: 6012,
|
||||
ttl_30m_tokens: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
prompt_cache_options: { ttl: '12h' },
|
||||
}),
|
||||
);
|
||||
|
||||
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 5,
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 11,
|
||||
cache_write_12h_tokens: 6012,
|
||||
});
|
||||
expect(luna.costs.cache_write_12h_tokens).toBe(
|
||||
Number(luna.costs.prompt_tokens) * 2.5,
|
||||
);
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 3 * Number(luna.costs.prompt_tokens),
|
||||
completion_tokens: 5 * Number(luna.costs.completion_tokens),
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 11 * Number(luna.costs.cache_write_tokens),
|
||||
cache_write_12h_tokens:
|
||||
6012 * Number(luna.costs.cache_write_12h_tokens),
|
||||
});
|
||||
expect((result as { usageDetails: unknown }).usageDetails).toEqual({
|
||||
inputTokens: 3,
|
||||
outputTokens: 5,
|
||||
cacheWrite30mTokens: 11,
|
||||
cacheWrite12hTokens: 6012,
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards prompt_cache_options and the wire spelling of prompt_cache_retention', async () => {
|
||||
const { provider } = makeProvider();
|
||||
responsesCreateMock.mockResolvedValueOnce({
|
||||
output: [{ role: 'assistant' }],
|
||||
output_text: 'ok',
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
prompt_cache_retention: 'in-memory',
|
||||
prompt_cache_options: {
|
||||
mode: 'explicit',
|
||||
ttl: '12h',
|
||||
comparison_response_id: 'resp_1',
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = responsesCreateMock.mock.calls[0]!;
|
||||
expect(args.prompt_cache_retention).toBe('in_memory');
|
||||
expect(args.prompt_cache_options).toEqual({
|
||||
mode: 'explicit',
|
||||
ttl: '12h',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a cache lifetime OpenAI does not offer before calling upstream', async () => {
|
||||
const { provider } = makeProvider();
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
prompt_cache_options: { ttl: '2h' } as never,
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(responsesCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -1068,6 +1178,69 @@ describe('OpenAiResponsesChatProvider.complete streaming', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('labels streamed cache writes by lifetime on the final usage chunk', async () => {
|
||||
const luna = OPEN_AI_MODELS.find((m) => m.id === 'gpt-6-luna')!;
|
||||
const { provider } = makeProvider();
|
||||
responsesCreateMock.mockReturnValueOnce(
|
||||
asAsyncIterable([
|
||||
{ type: 'response.output_text.delta', delta: 'ok' },
|
||||
{
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
usage: {
|
||||
input_tokens: 6026,
|
||||
output_tokens: 5,
|
||||
input_tokens_details: {
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 6023,
|
||||
cache_write_tokens_details: {
|
||||
ttl_12h_tokens: 6012,
|
||||
ttl_30m_tokens: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'say ok' }],
|
||||
stream: true,
|
||||
prompt_cache_options: { ttl: '12h' },
|
||||
}),
|
||||
);
|
||||
const harness = makeCapturingChatStream();
|
||||
await (
|
||||
result as {
|
||||
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
|
||||
}
|
||||
).init_chat_stream({ chatStream: harness.chatStream });
|
||||
|
||||
const usageEvent = harness.events().find((e) => e.type === 'usage');
|
||||
expect(usageEvent?.usage).toEqual({
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 5,
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 11,
|
||||
cache_write_12h_tokens: 6012,
|
||||
});
|
||||
expect(usageEvent?.usageDetails).toEqual({
|
||||
inputTokens: 3,
|
||||
outputTokens: 5,
|
||||
cacheWrite30mTokens: 11,
|
||||
cacheWrite12hTokens: 6012,
|
||||
});
|
||||
const [, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(overrides).toMatchObject({
|
||||
cache_write_tokens: 11 * Number(luna.costs.cache_write_tokens),
|
||||
cache_write_12h_tokens:
|
||||
6012 * Number(luna.costs.cache_write_12h_tokens),
|
||||
});
|
||||
});
|
||||
|
||||
it('emits a tool_use block when response.output_item.done arrives with a function_call', async () => {
|
||||
const { provider } = makeProvider();
|
||||
responsesCreateMock.mockReturnValueOnce(
|
||||
|
||||
@@ -32,6 +32,8 @@ import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
|
||||
import {
|
||||
clampReasoningEffort,
|
||||
openAICompatParams,
|
||||
promptCacheOptionsToWire,
|
||||
promptCacheRetentionToWire,
|
||||
rejectStatefulResponsesFields,
|
||||
} from '../../utils/openaiParams.js';
|
||||
import { responseSamplingParams } from '../../utils/responseSampling.js';
|
||||
@@ -150,6 +152,7 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
prompt,
|
||||
prompt_cache_key,
|
||||
prompt_cache_retention,
|
||||
prompt_cache_options,
|
||||
store,
|
||||
top_p,
|
||||
truncation,
|
||||
@@ -195,6 +198,10 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
|
||||
const cacheKey = prompt_cache_key ?? userIdentifier;
|
||||
const cacheRetention = promptCacheRetentionToWire(
|
||||
prompt_cache_retention,
|
||||
);
|
||||
const cacheOptions = promptCacheOptionsToWire(prompt_cache_options);
|
||||
|
||||
if (tools) {
|
||||
// Unravel tools to OpenAI Responses API format
|
||||
@@ -288,8 +295,11 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
...(instructions !== undefined ? { instructions } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}),
|
||||
...(prompt_cache_retention !== undefined
|
||||
? { prompt_cache_retention }
|
||||
...(cacheRetention !== undefined
|
||||
? { prompt_cache_retention: cacheRetention }
|
||||
: {}),
|
||||
...(cacheOptions !== undefined
|
||||
? { prompt_cache_options: cacheOptions }
|
||||
: {}),
|
||||
...(store !== undefined ? { store } : {}),
|
||||
...(max_tokens !== undefined
|
||||
@@ -310,24 +320,17 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
{ signal: Context.get('upstreamSignal') },
|
||||
);
|
||||
return OpenAiUtil.handle_completion_output_responses_api({
|
||||
usage_calculator: ({ usage, webSearchCalls, setUsageCosts }) => {
|
||||
const cachedTokens =
|
||||
(usage as any).input_tokens_details?.cached_tokens ?? 0;
|
||||
// GPT-5.6 and later bill cache writes at 1.25x input. They're
|
||||
// reported inside `input_tokens`, like cached reads.
|
||||
const cacheWriteTokens =
|
||||
(usage as any).input_tokens_details?.cache_write_tokens ??
|
||||
0;
|
||||
usage_calculator: ({
|
||||
usage,
|
||||
webSearchCalls,
|
||||
setUsageCosts,
|
||||
setUsageDetails,
|
||||
}) => {
|
||||
// GPT-5.6 and later report cache writes inside `input_tokens`,
|
||||
// like cached reads, split by lifetime (30m and 12h entries
|
||||
// cost 1.25x and 2.5x input to write).
|
||||
const trackedUsage: Record<string, number> = {
|
||||
prompt_tokens:
|
||||
((usage as any).input_tokens ?? 0) -
|
||||
cachedTokens -
|
||||
cacheWriteTokens,
|
||||
completion_tokens: (usage as any).output_tokens ?? 0,
|
||||
cached_tokens: cachedTokens,
|
||||
...(cacheWriteTokens
|
||||
? { cache_write_tokens: cacheWriteTokens }
|
||||
: {}),
|
||||
...OpenAiUtil.splitCachedPrompt(usage),
|
||||
...(webSearchCalls
|
||||
? { web_search_calls: webSearchCalls }
|
||||
: {}),
|
||||
@@ -356,6 +359,11 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
: {},
|
||||
);
|
||||
setUsageCosts?.(metered.costs);
|
||||
setUsageDetails?.(
|
||||
OpenAiUtil.usageDetailsFromTrackedUsage(metered.usage, {
|
||||
writeTtl: '30m',
|
||||
})!,
|
||||
);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
|
||||
@@ -23,6 +23,8 @@ import type { IChatModel } from '../../types.js';
|
||||
|
||||
// Prompts over 272K input tokens are billed at 2x input (cached reads and
|
||||
// cache writes included) and 1.5x output for the full request.
|
||||
// GPT-5.6 and later bill cache writes by the lifetime requested: 1.25x input
|
||||
// for the default 30m entries, 2.5x for 12h entries.
|
||||
const GPT_LONG_CONTEXT_PRICING = {
|
||||
threshold: 272_000,
|
||||
input_multiplier: 2,
|
||||
@@ -49,6 +51,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 100,
|
||||
cached_tokens: 5,
|
||||
cache_write_tokens: 100 * 1.25,
|
||||
cache_write_12h_tokens: 100 * 2.5,
|
||||
completion_tokens: 500,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
@@ -74,6 +77,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 200,
|
||||
cached_tokens: 10,
|
||||
cache_write_tokens: 200 * 1.25,
|
||||
cache_write_12h_tokens: 200 * 2.5,
|
||||
completion_tokens: 1000,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
@@ -99,6 +103,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 200,
|
||||
cached_tokens: 20,
|
||||
cache_write_tokens: 200 * 1.25,
|
||||
cache_write_12h_tokens: 200 * 2.5,
|
||||
completion_tokens: 1000,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
@@ -124,6 +129,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 10,
|
||||
cached_tokens: 1,
|
||||
cache_write_tokens: 10 * 1.25,
|
||||
cache_write_12h_tokens: 10 * 2.5,
|
||||
completion_tokens: 50,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
@@ -149,6 +155,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 1000,
|
||||
cached_tokens: 100,
|
||||
cache_write_tokens: 1000 * 1.25,
|
||||
cache_write_12h_tokens: 1000 * 2.5,
|
||||
completion_tokens: 5000,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
@@ -174,6 +181,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 400,
|
||||
cached_tokens: 40,
|
||||
cache_write_tokens: 400 * 1.25,
|
||||
cache_write_12h_tokens: 400 * 2.5,
|
||||
completion_tokens: 2000,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
@@ -198,6 +206,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 200,
|
||||
cached_tokens: 20,
|
||||
cache_write_tokens: 200 * 1.25,
|
||||
cache_write_12h_tokens: 200 * 2.5,
|
||||
completion_tokens: 1200,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
@@ -222,6 +231,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 20,
|
||||
cached_tokens: 2,
|
||||
cache_write_tokens: 20 * 1.25,
|
||||
cache_write_12h_tokens: 20 * 2.5,
|
||||
completion_tokens: 120,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
|
||||
@@ -145,13 +145,19 @@ export type StopReason =
|
||||
| 'model_context_window_exceeded'
|
||||
| (string & {});
|
||||
|
||||
/** Provider-neutral usage breakdown. `inputTokens` excludes cache reads/writes. */
|
||||
/**
|
||||
* Provider-neutral usage breakdown. `inputTokens` excludes cache reads/writes.
|
||||
* Cache writes are keyed by the lifetime the entry was written with: Claude and
|
||||
* Moonshot write 5m and 1h entries, OpenAI writes 30m and 12h entries.
|
||||
*/
|
||||
export interface UsageDetails {
|
||||
inputTokens: number;
|
||||
outputTokens: number; // includes reasoning
|
||||
cacheReadTokens?: number;
|
||||
cacheWrite5mTokens?: number;
|
||||
cacheWrite30mTokens?: number;
|
||||
cacheWrite1hTokens?: number;
|
||||
cacheWrite12hTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
webSearchRequests?: number;
|
||||
webFetchRequests?: number;
|
||||
@@ -168,10 +174,22 @@ export interface UsageDetails {
|
||||
outputTokens: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheWrite5mTokens?: number;
|
||||
cacheWrite30mTokens?: number;
|
||||
cacheWrite1hTokens?: number;
|
||||
cacheWrite12hTokens?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI `prompt_cache_options`, GPT-5.6 and later. `ttl` is the minimum
|
||||
* lifetime of every cache entry the request writes; `12h` entries cost twice
|
||||
* what `30m` entries do to write and only the Responses API writes them.
|
||||
*/
|
||||
export interface PromptCacheOptions {
|
||||
mode?: 'implicit' | 'explicit';
|
||||
ttl?: '30m' | '12h';
|
||||
}
|
||||
|
||||
export interface ICompleteArguments {
|
||||
messages: PuterMessage[];
|
||||
provider?: string;
|
||||
@@ -234,7 +252,9 @@ export interface ICompleteArguments {
|
||||
metadata?: Record<string, string>;
|
||||
prompt?: unknown;
|
||||
prompt_cache_key?: string;
|
||||
prompt_cache_retention?: 'in-memory' | '24h' | undefined;
|
||||
/** `in-memory` is accepted as a spelling of OpenAI's `in_memory`. */
|
||||
prompt_cache_retention?: 'in-memory' | 'in_memory' | '24h' | undefined;
|
||||
prompt_cache_options?: PromptCacheOptions;
|
||||
store?: boolean;
|
||||
top_p?: number;
|
||||
truncation?: 'auto' | 'disabled' | undefined;
|
||||
|
||||
@@ -481,43 +481,86 @@ export const extractMeteredUsage = (usage) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Chat Completions usage as it's metered: `prompt_tokens` reports cached reads
|
||||
* inside its count, so they come out of it and are priced only once, under
|
||||
* `cached_tokens`.
|
||||
* @typedef {{
|
||||
* cached_tokens?: number | null;
|
||||
* cache_write_tokens?: number | null;
|
||||
* cache_write_tokens_details?: {
|
||||
* ttl_30m_tokens?: number | null;
|
||||
* ttl_12h_tokens?: number | null;
|
||||
* } | null;
|
||||
* }} OpenAiCacheDetails
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAI-shaped usage as it's metered. The prompt count reports cached reads
|
||||
* and cache writes inside it, so they come out of it and are priced once each
|
||||
* under their own keys: `cached_tokens` for reads, `cache_write_tokens` for
|
||||
* writes at the model's default lifetime and `cache_write_12h_tokens` for
|
||||
* writes at OpenAI's 12h lifetime. Without the per-lifetime breakdown every
|
||||
* write is a default-lifetime write: Chat Completions writes no 12h entries,
|
||||
* and Responses reports the breakdown whenever a request asked for them. Reads
|
||||
* the Chat Completions (`prompt_tokens`) and Responses (`input_tokens`)
|
||||
* spellings alike.
|
||||
*
|
||||
* @param {import('openai/resources/completions.mjs').CompletionUsage | undefined} usage
|
||||
* @param {{
|
||||
* prompt_tokens?: number;
|
||||
* completion_tokens?: number;
|
||||
* prompt_tokens_details?: OpenAiCacheDetails | null;
|
||||
* input_tokens?: number;
|
||||
* output_tokens?: number;
|
||||
* input_tokens_details?: OpenAiCacheDetails | null;
|
||||
* }
|
||||
* | undefined} usage
|
||||
*/
|
||||
export const splitCachedPrompt = (usage) => {
|
||||
const details = usage?.prompt_tokens_details;
|
||||
const details = usage?.prompt_tokens_details ?? usage?.input_tokens_details;
|
||||
const cachedTokens = details?.cached_tokens ?? 0;
|
||||
// Moonshot counts the prompt tokens it wrote to its prefix cache here;
|
||||
// they're billed at the model's write rate, not the plain prompt rate.
|
||||
// Moonshot and GPT-5.6+ count the prompt tokens they wrote to the prefix
|
||||
// cache here; they're billed at the model's write rate, not the prompt rate.
|
||||
const writeTokens =
|
||||
typeof details?.cache_write_tokens === 'number'
|
||||
? details.cache_write_tokens
|
||||
: undefined;
|
||||
const reported12h = details?.cache_write_tokens_details?.ttl_12h_tokens;
|
||||
const write12hTokens =
|
||||
typeof reported12h === 'number'
|
||||
? Math.min(reported12h, writeTokens ?? 0)
|
||||
: undefined;
|
||||
return {
|
||||
prompt_tokens:
|
||||
(usage?.prompt_tokens ?? 0) - cachedTokens - (writeTokens ?? 0),
|
||||
completion_tokens: usage?.completion_tokens ?? 0,
|
||||
(usage?.prompt_tokens ?? usage?.input_tokens ?? 0) -
|
||||
cachedTokens -
|
||||
(writeTokens ?? 0),
|
||||
completion_tokens:
|
||||
usage?.completion_tokens ?? usage?.output_tokens ?? 0,
|
||||
cached_tokens: cachedTokens,
|
||||
...(writeTokens !== undefined
|
||||
? { cache_write_tokens: writeTokens }
|
||||
? { cache_write_tokens: writeTokens - (write12hTokens ?? 0) }
|
||||
: {}),
|
||||
...(write12hTokens !== undefined
|
||||
? { cache_write_12h_tokens: write12hTokens }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The `{prompt_tokens, completion_tokens, cached_tokens, cache_write_tokens}`
|
||||
* shape every OpenAI-family `usage_calculator` already returns for billing,
|
||||
* mapped to the provider-neutral `UsageDetails` the end-of-stream/non-stream
|
||||
* result carries. `inputTokens`/`outputTokens` are the same numbers already
|
||||
* billed — this never introduces a new figure, only exposes the breakdown.
|
||||
* The `{prompt_tokens, completion_tokens, cached_tokens, cache_write_tokens,
|
||||
* cache_write_12h_tokens}` shape every OpenAI-family `usage_calculator` already
|
||||
* returns for billing, mapped to the provider-neutral `UsageDetails` the
|
||||
* end-of-stream/non-stream result carries. `inputTokens`/`outputTokens` are the
|
||||
* same numbers already billed — this never introduces a new figure, only
|
||||
* exposes the breakdown.
|
||||
*
|
||||
* @param {Record<string, number> | undefined | null} trackedUsage
|
||||
* @param {{ writeTtl?: '5m' | '30m' }} [opts] The lifetime `cache_write_tokens`
|
||||
* were written with: 5m unless the provider says otherwise (OpenAI's default
|
||||
* entries live 30m).
|
||||
* @returns {import('../types.js').UsageDetails | undefined}
|
||||
*/
|
||||
export const usageDetailsFromTrackedUsage = (trackedUsage) => {
|
||||
export const usageDetailsFromTrackedUsage = (
|
||||
trackedUsage,
|
||||
{ writeTtl = '5m' } = {},
|
||||
) => {
|
||||
if (!trackedUsage || typeof trackedUsage !== 'object') return undefined;
|
||||
// OpenRouter, Infron and Ollama track `{prompt, completion,
|
||||
// input_cache_read}` instead; any other shape is left to the driver.
|
||||
@@ -526,12 +569,19 @@ export const usageDetailsFromTrackedUsage = (trackedUsage) => {
|
||||
if (input === undefined && output === undefined) return undefined;
|
||||
const cacheRead =
|
||||
trackedUsage.cached_tokens ?? trackedUsage.input_cache_read;
|
||||
const cacheWrite = trackedUsage.cache_write_tokens;
|
||||
return {
|
||||
inputTokens: input ?? 0,
|
||||
outputTokens: output ?? 0,
|
||||
...(cacheRead ? { cacheReadTokens: cacheRead } : {}),
|
||||
...(trackedUsage.cache_write_tokens
|
||||
? { cacheWrite5mTokens: trackedUsage.cache_write_tokens }
|
||||
...(cacheWrite && writeTtl === '30m'
|
||||
? { cacheWrite30mTokens: cacheWrite }
|
||||
: {}),
|
||||
...(cacheWrite && writeTtl !== '30m'
|
||||
? { cacheWrite5mTokens: cacheWrite }
|
||||
: {}),
|
||||
...(trackedUsage.cache_write_12h_tokens
|
||||
? { cacheWrite12hTokens: trackedUsage.cache_write_12h_tokens }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
@@ -864,12 +914,16 @@ export const create_chat_stream_handler_responses_api =
|
||||
// Missing usage is left undefined rather than fed to the calculator —
|
||||
// see the sibling handler above, including why usage is reported
|
||||
// before the block flushes.
|
||||
let usageDetails;
|
||||
const usage = last_usage
|
||||
? usage_calculator({
|
||||
usage: last_usage,
|
||||
webSearchCalls,
|
||||
tool_usage: toolUsage,
|
||||
setUsageCosts: (c) => chatStream.setUsageCosts(c),
|
||||
setUsageDetails: (d) => {
|
||||
usageDetails = d;
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
chatStream.reportUsage(usage);
|
||||
@@ -877,7 +931,7 @@ export const create_chat_stream_handler_responses_api =
|
||||
reason:
|
||||
incompleteReason ?? (sawFunctionCall ? 'tool_use' : 'end_turn'),
|
||||
});
|
||||
const usageDetails = usageDetailsFromTrackedUsage(usage);
|
||||
usageDetails ??= usageDetailsFromTrackedUsage(usage);
|
||||
if (usageDetails) chatStream.setUsageDetails(usageDetails);
|
||||
|
||||
textblock.end();
|
||||
@@ -1015,6 +1069,7 @@ export const handle_completion_output = async (
|
||||
* webSearchCalls?: number;
|
||||
* tool_usage?: Record<string, unknown>;
|
||||
* setUsageCosts?: (costs: Record<string, number>) => void;
|
||||
* setUsageDetails?: (details: import('../types').UsageDetails) => void;
|
||||
* }) => unknown} params.usage_calculator
|
||||
* @param {() => Promise<void>} [params.finally_fn]
|
||||
* @returns {ReturnType<import('../types').IChatProvider['complete']>}
|
||||
@@ -1156,12 +1211,15 @@ export const handle_completion_output_responses_api = async ({
|
||||
setUsageCosts: (c) => {
|
||||
ret.usageCosts = c;
|
||||
},
|
||||
setUsageDetails: (d) => {
|
||||
ret.usageDetails = d;
|
||||
},
|
||||
})
|
||||
: {
|
||||
input_tokens: completion.usage.input_tokens,
|
||||
output_tokens: completion.usage.output_tokens,
|
||||
};
|
||||
if (usage_calculator) {
|
||||
if (usage_calculator && ret.usageDetails === undefined) {
|
||||
const usageDetails = usageDetailsFromTrackedUsage(ret.usage);
|
||||
if (usageDetails) ret.usageDetails = usageDetails;
|
||||
}
|
||||
|
||||
@@ -247,6 +247,40 @@ describe('usageDetailsFromTrackedUsage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('labels cache writes by the lifetime the provider says it wrote them with', () => {
|
||||
expect(
|
||||
usageDetailsFromTrackedUsage(
|
||||
{
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
cached_tokens: 3,
|
||||
cache_write_tokens: 11,
|
||||
cache_write_12h_tokens: 6012,
|
||||
},
|
||||
{ writeTtl: '30m' },
|
||||
),
|
||||
).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: 3,
|
||||
cacheWrite30mTokens: 11,
|
||||
cacheWrite12hTokens: 6012,
|
||||
});
|
||||
// Zero counts, as OpenAI reports on a cache hit, add no field.
|
||||
expect(
|
||||
usageDetailsFromTrackedUsage(
|
||||
{
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
cached_tokens: 3,
|
||||
cache_write_tokens: 0,
|
||||
cache_write_12h_tokens: 0,
|
||||
},
|
||||
{ writeTtl: '30m' },
|
||||
),
|
||||
).toEqual({ inputTokens: 10, outputTokens: 5, cacheReadTokens: 3 });
|
||||
});
|
||||
|
||||
it('returns undefined for a missing/non-object usage', () => {
|
||||
expect(usageDetailsFromTrackedUsage(undefined)).toBeUndefined();
|
||||
expect(usageDetailsFromTrackedUsage(null)).toBeUndefined();
|
||||
@@ -962,6 +996,66 @@ describe('splitCachedPrompt', () => {
|
||||
cached_tokens: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the Responses usage spelling', () => {
|
||||
expect(
|
||||
splitCachedPrompt({
|
||||
input_tokens: 6026,
|
||||
output_tokens: 5,
|
||||
input_tokens_details: { cached_tokens: 0, cache_write_tokens: 6023 },
|
||||
}),
|
||||
).toEqual({
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 5,
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 6023,
|
||||
});
|
||||
});
|
||||
|
||||
it('splits 12h writes out of the write count when the breakdown is reported', () => {
|
||||
// One request can write both lifetimes: an explicit 12h breakpoint
|
||||
// plus the implicit 30m one OpenAI adds at the end of the prompt.
|
||||
expect(
|
||||
splitCachedPrompt({
|
||||
input_tokens: 6026,
|
||||
output_tokens: 5,
|
||||
input_tokens_details: {
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 6023,
|
||||
cache_write_tokens_details: {
|
||||
ttl_12h_tokens: 6012,
|
||||
ttl_30m_tokens: 11,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 5,
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 11,
|
||||
cache_write_12h_tokens: 6012,
|
||||
});
|
||||
});
|
||||
|
||||
it('never reports more 12h writes than writes in total', () => {
|
||||
expect(
|
||||
splitCachedPrompt({
|
||||
input_tokens: 100,
|
||||
output_tokens: 1,
|
||||
input_tokens_details: {
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 40,
|
||||
cache_write_tokens_details: { ttl_12h_tokens: 50 },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
prompt_tokens: 60,
|
||||
completion_tokens: 1,
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
cache_write_12h_tokens: 40,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── recorded usage costs ────────────────────────────────────────────
|
||||
|
||||
@@ -22,11 +22,14 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { ICompleteArguments } from '../types.js';
|
||||
import {
|
||||
clampReasoningEffort,
|
||||
messagesHaveCacheBreakpointTtl,
|
||||
openAICompatParams,
|
||||
outputFormatFromResponseFormat,
|
||||
outputFormatFromResponsesText,
|
||||
outputFormatToResponseFormat,
|
||||
outputFormatToResponsesText,
|
||||
promptCacheOptionsToWire,
|
||||
promptCacheRetentionToWire,
|
||||
rejectStatefulResponsesFields,
|
||||
toolChoiceFromWire,
|
||||
toolChoiceToWire,
|
||||
@@ -334,3 +337,109 @@ describe('rejectStatefulResponsesFields', () => {
|
||||
).toThrow(/previous_response_id/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptCacheRetentionToWire', () => {
|
||||
it('spells the in-memory retention the way the upstreams expect', () => {
|
||||
expect(promptCacheRetentionToWire('in-memory')).toBe('in_memory');
|
||||
expect(promptCacheRetentionToWire('in_memory')).toBe('in_memory');
|
||||
expect(promptCacheRetentionToWire('24h')).toBe('24h');
|
||||
expect(promptCacheRetentionToWire(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptCacheOptionsToWire', () => {
|
||||
it('forwards mode and ttl and nothing else', () => {
|
||||
expect(
|
||||
promptCacheOptionsToWire({
|
||||
mode: 'explicit',
|
||||
ttl: '12h',
|
||||
prewarm: true,
|
||||
comparison_response_id: 'resp_1',
|
||||
}),
|
||||
).toEqual({ mode: 'explicit', ttl: '12h' });
|
||||
expect(promptCacheOptionsToWire({ ttl: '30m' })).toEqual({
|
||||
ttl: '30m',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends nothing for an absent or empty object', () => {
|
||||
expect(promptCacheOptionsToWire(undefined)).toBeUndefined();
|
||||
expect(promptCacheOptionsToWire(null)).toBeUndefined();
|
||||
expect(promptCacheOptionsToWire({})).toBeUndefined();
|
||||
expect(promptCacheOptionsToWire({ prewarm: true })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a lifetime or mode OpenAI does not offer', () => {
|
||||
expect(() => promptCacheOptionsToWire({ ttl: '2h' })).toThrow(
|
||||
expect.objectContaining({ statusCode: 400 }),
|
||||
);
|
||||
expect(() => promptCacheOptionsToWire({ ttl: '24h' })).toThrow(
|
||||
/30m.*12h/,
|
||||
);
|
||||
expect(() => promptCacheOptionsToWire({ mode: 'auto' })).toThrow(
|
||||
/implicit.*explicit/,
|
||||
);
|
||||
expect(() => promptCacheOptionsToWire('12h')).toThrow(
|
||||
/must be an object/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('messagesHaveCacheBreakpointTtl', () => {
|
||||
it('finds a breakpoint lifetime on any content part', () => {
|
||||
expect(
|
||||
messagesHaveCacheBreakpointTtl([
|
||||
{ role: 'user', content: 'plain' },
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
{ type: 'text', text: 'a' },
|
||||
{
|
||||
type: 'text',
|
||||
text: 'b',
|
||||
prompt_cache_breakpoint: {
|
||||
mode: 'explicit',
|
||||
ttl: '30m',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
messagesHaveCacheBreakpointTtl([
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'a',
|
||||
prompt_cache_breakpoint: { ttl: '12h' },
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores breakpoints without a lifetime and odd message shapes', () => {
|
||||
expect(
|
||||
messagesHaveCacheBreakpointTtl([
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'a',
|
||||
prompt_cache_breakpoint: { mode: 'explicit' },
|
||||
},
|
||||
'bare string part',
|
||||
null,
|
||||
],
|
||||
},
|
||||
{ role: 'user', content: null },
|
||||
null,
|
||||
]),
|
||||
).toBe(false);
|
||||
expect(messagesHaveCacheBreakpointTtl(undefined)).toBe(false);
|
||||
expect(messagesHaveCacheBreakpointTtl('nope')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import { HttpError } from '../../../core/http/HttpError.js';
|
||||
import type {
|
||||
ICompleteArguments,
|
||||
OutputFormat,
|
||||
PromptCacheOptions,
|
||||
ReasoningEffort,
|
||||
ToolChoice,
|
||||
} from '../types.js';
|
||||
@@ -305,6 +306,80 @@ export const openAICompatParams = (
|
||||
return out;
|
||||
};
|
||||
|
||||
// -- prompt caching ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The wire spelling of `prompt_cache_retention`. Puter also accepts the
|
||||
* hyphenated `in-memory`, which every OpenAI-shaped upstream rejects.
|
||||
*/
|
||||
export const promptCacheRetentionToWire = (
|
||||
retention: ICompleteArguments['prompt_cache_retention'],
|
||||
): 'in_memory' | '24h' | undefined =>
|
||||
retention === 'in-memory' ? 'in_memory' : retention;
|
||||
|
||||
const PROMPT_CACHE_MODES = new Set(['implicit', 'explicit']);
|
||||
const PROMPT_CACHE_TTLS = new Set(['30m', '12h']);
|
||||
|
||||
/**
|
||||
* `prompt_cache_options` as forwarded upstream: `mode` and `ttl` only, each
|
||||
* checked against the values OpenAI accepts, so a bad value fails here with a
|
||||
* clear message instead of as an upstream 400 the fallback loop may hide.
|
||||
* `comparison_response_id` names a stored response, which Puter never keeps,
|
||||
* and `prewarm` is dropped with it.
|
||||
*/
|
||||
export const promptCacheOptionsToWire = (
|
||||
options: unknown,
|
||||
): PromptCacheOptions | undefined => {
|
||||
if (options === undefined || options === null) return undefined;
|
||||
if (typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new HttpError(400, '`prompt_cache_options` must be an object', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const { mode, ttl } = options as Record<string, unknown>;
|
||||
if (mode !== undefined && !PROMPT_CACHE_MODES.has(mode as string)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`prompt_cache_options.mode` must be `implicit` or `explicit`',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
if (ttl !== undefined && !PROMPT_CACHE_TTLS.has(ttl as string)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`prompt_cache_options.ttl` must be `30m` or `12h`',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
const wire: PromptCacheOptions = {
|
||||
...(mode !== undefined
|
||||
? { mode: mode as PromptCacheOptions['mode'] }
|
||||
: {}),
|
||||
...(ttl !== undefined ? { ttl: ttl as PromptCacheOptions['ttl'] } : {}),
|
||||
};
|
||||
return Object.keys(wire).length ? wire : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether any content part carries a `prompt_cache_breakpoint` with its own
|
||||
* `ttl`. Chat Completions rejects the field whatever its value; only the
|
||||
* Responses API honours a per-breakpoint lifetime.
|
||||
*/
|
||||
export const messagesHaveCacheBreakpointTtl = (messages: unknown): boolean =>
|
||||
Array.isArray(messages) &&
|
||||
messages.some((message) => {
|
||||
const content = (message as { content?: unknown } | null)?.content;
|
||||
const parts = Array.isArray(content) ? content : [content];
|
||||
return parts.some(
|
||||
(part) =>
|
||||
(
|
||||
part as {
|
||||
prompt_cache_breakpoint?: { ttl?: unknown } | null;
|
||||
} | null
|
||||
)?.prompt_cache_breakpoint?.ttl !== undefined,
|
||||
);
|
||||
});
|
||||
|
||||
// -- Responses server-side state ---------------------------------------------
|
||||
|
||||
const STATEFUL_RESPONSES_FIELDS = [
|
||||
|
||||
@@ -463,10 +463,26 @@ describe('ImageGenerationDriver.generate provider routing', () => {
|
||||
driver.generate({ prompt: 'a landscape' }),
|
||||
);
|
||||
expect(openaiImagesGenerateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: 'gpt-image-2' }),
|
||||
expect.objectContaining({ model: 'gpt-image-2.6-flare' }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['gpt-image-2.6-flare', 'gpt-image-2.6-sunburst'])(
|
||||
'routes %s and its OpenAI alias', async (model) => {
|
||||
for (const id of [model, `openai/${model}`]) {
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://oai/image.png' }],
|
||||
});
|
||||
await withDriverName('ai-image', () => driver.generate({
|
||||
model: id, prompt: 'a landscape',
|
||||
}));
|
||||
expect(openaiImagesGenerateMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ model }),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an excluded model regardless of caller casing', async () => {
|
||||
await expect(withActor(() =>
|
||||
driver.generate({
|
||||
@@ -742,7 +758,10 @@ describe('ImageGenerationDriver.generate provider routing', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const reserveSpy = vi.spyOn(server.services.metering, 'reserveCredits');
|
||||
const reserveSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'reserveCreditsIfAffordable',
|
||||
);
|
||||
const usageSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'batchIncrementUsages',
|
||||
@@ -836,7 +855,7 @@ describe('ImageGenerationDriver.generate provider routing', () => {
|
||||
});
|
||||
const reserveSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'reserveCredits',
|
||||
'reserveCreditsIfAffordable',
|
||||
);
|
||||
|
||||
// Cloudflare answers the scheduled refresh with new rates at once;
|
||||
@@ -1146,8 +1165,8 @@ describe('ImageGenerationDriver.generate Puter file inputs', () => {
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(server.services.metering, 'hasEnoughCredits').mockResolvedValue(
|
||||
true,
|
||||
vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable').mockImplementation((who, amount, opts) =>
|
||||
server.services.metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1649,7 +1668,10 @@ it.each([undefined, null, '', ' ', 42, {}])(
|
||||
|
||||
describe('ImageGenerationDriver cost factor', () => {
|
||||
it('holds and meters at the AI cost factor', async () => {
|
||||
const reserveSpy = vi.spyOn(server.services.metering, 'reserveCredits');
|
||||
const reserveSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'reserveCreditsIfAffordable',
|
||||
);
|
||||
const usageSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'batchIncrementUsages',
|
||||
|
||||
@@ -72,7 +72,7 @@ vi.mock('openai', () => {
|
||||
// -- Test harness ----------------------------------------------------
|
||||
|
||||
let server: PuterServer;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let batchIncrementUsagesSpy: MockInstance<
|
||||
MeteringService['batchIncrementUsages']
|
||||
>;
|
||||
@@ -107,7 +107,7 @@ const makeProvider = (
|
||||
beforeEach(() => {
|
||||
generateMock.mockReset();
|
||||
openAICtor.mockReset();
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
batchIncrementUsagesSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'batchIncrementUsages',
|
||||
@@ -177,7 +177,7 @@ describe('BytePlusImageProvider.generate gates', () => {
|
||||
expect(result).toBe(
|
||||
'https://puter-sample-data.puter.site/image_example.png',
|
||||
);
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -196,7 +196,7 @@ describe('BytePlusImageProvider.generate gates', () => {
|
||||
});
|
||||
|
||||
it('throws 402 BEFORE hitting ModelArk when the actor lacks credits', async () => {
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
await expect(
|
||||
withTestActor(() => makeProvider().generate({ prompt: 'hi' })),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
@@ -713,7 +713,7 @@ it.each([
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ let server: PuterServer;
|
||||
let globalFetchSpy: MockInstance<typeof fetch>;
|
||||
let fetchSpy: Mock<typeof fetch>;
|
||||
let modelSearchFetch: Mock<typeof fetch>;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let batchIncrementUsagesSpy: MockInstance<
|
||||
MeteringService['batchIncrementUsages']
|
||||
>;
|
||||
@@ -123,7 +123,7 @@ beforeEach(() => {
|
||||
? modelSearchFetch(input, init)
|
||||
: fetchSpy(input, init),
|
||||
);
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
batchIncrementUsagesSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'batchIncrementUsages',
|
||||
@@ -188,7 +188,7 @@ describe('CloudflareImageProvider.generate test_mode', () => {
|
||||
expect(result).toBe(
|
||||
'https://puter-sample-data.puter.site/image_example.png',
|
||||
);
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -210,7 +210,7 @@ describe('CloudflareImageProvider.generate argument validation', () => {
|
||||
describe('CloudflareImageProvider.generate credit gate', () => {
|
||||
it('throws 402 BEFORE hitting Cloudflare when actor lacks credits', async () => {
|
||||
const provider = makeProvider();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
withTestActor(() => provider.generate({ prompt: 'hi' })),
|
||||
@@ -240,7 +240,7 @@ describe('CloudflareImageProvider.generate credit gate', () => {
|
||||
actor,
|
||||
);
|
||||
|
||||
const [, estimate] = hasCreditsSpy.mock.calls[0]!;
|
||||
const [, estimate] = admitSpy.mock.calls[0]!;
|
||||
expect(estimate).toBeGreaterThan(0);
|
||||
expect(heldDuringCall).toBe(Math.ceil(estimate));
|
||||
// The hold stays until the usage write lands.
|
||||
@@ -708,7 +708,7 @@ describe('Cloudflare legacy image option', () => {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -726,7 +726,7 @@ describe('Cloudflare legacy image option', () => {
|
||||
message: 'Invalid input image (empty or not base64)',
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -823,12 +823,12 @@ describe('Cloudflare priced legacy models', () => {
|
||||
imageSize: { kind: 'pixels', w: 512, h: 512 },
|
||||
}));
|
||||
expect(JSON.parse(fetchSpy.mock.calls[0]![1]!.body as string)).toMatchObject({ image: [1, 2, 3], mask: [4, 5, 6], num_steps: 20, width: 512, height: 512 });
|
||||
expect(hasCreditsSpy).toHaveBeenCalledWith(expect.anything(), 0);
|
||||
expect(admitSpy).toHaveBeenCalledWith(expect.anything(), 0, undefined);
|
||||
});
|
||||
|
||||
it('rejects missing inpainting inputs before credit checks', async () => {
|
||||
await expect(withTestActor(() => makeProvider().generate({ model: '@cf/runwayml/stable-diffusion-v1-5-inpainting', prompt: 'A cup' }))).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -844,7 +844,7 @@ describe('Cloudflare priced legacy models', () => {
|
||||
it('charges Lucid at the price returned by the model API', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(new Uint8Array([1, 2, 3]), { headers: { 'content-type': 'image/png' } }));
|
||||
await withTestActor(() => makeProvider().generate({ model: '@cf/leonardo/lucid-origin', prompt: 'A cup', imageSize: { kind: 'pixels', w: 512, h: 512 }, steps: 1 }));
|
||||
expect(hasCreditsSpy).toHaveBeenCalledWith(expect.anything(), 713200);
|
||||
expect(admitSpy).toHaveBeenCalledWith(expect.anything(), 713200, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -897,9 +897,10 @@ describe('Cloudflare model-search pricing', () => {
|
||||
await runPhoenix(provider);
|
||||
|
||||
expect(listed?.costs).toEqual({ tile_512: 1_000_000, step: 20_200 });
|
||||
expect(hasCreditsSpy).toHaveBeenCalledWith(
|
||||
expect(admitSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
4 * 1_000_000 + 10 * 20_200,
|
||||
undefined,
|
||||
);
|
||||
expect(batchIncrementUsagesSpy.mock.calls[0]![1]).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -930,9 +931,10 @@ describe('Cloudflare model-search pricing', () => {
|
||||
await runPhoenix(makeProvider());
|
||||
|
||||
expect(modelSearchFetch).not.toHaveBeenCalled();
|
||||
expect(hasCreditsSpy).toHaveBeenCalledWith(
|
||||
expect(admitSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
4 * 583_000 + 10 * 11_000,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -955,7 +957,7 @@ describe('Cloudflare model-search pricing', () => {
|
||||
await refresh;
|
||||
await runPhoenix(provider);
|
||||
|
||||
expect(hasCreditsSpy.mock.calls.map(([, cost]) => cost)).toEqual([
|
||||
expect(admitSpy.mock.calls.map(([, cost]) => cost)).toEqual([
|
||||
2_442_000, 4_202_000,
|
||||
]);
|
||||
});
|
||||
@@ -977,9 +979,10 @@ describe('Cloudflare model-search pricing', () => {
|
||||
await runPhoenix(provider);
|
||||
|
||||
expect(modelSearchFetch).toHaveBeenCalledTimes(2);
|
||||
expect(hasCreditsSpy).toHaveBeenCalledWith(
|
||||
expect(admitSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
4_202_000,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1015,7 +1018,7 @@ describe('Cloudflare model-search pricing', () => {
|
||||
steps: 10,
|
||||
} as never),
|
||||
);
|
||||
return hasCreditsSpy.mock.lastCall![1];
|
||||
return admitSpy.mock.lastCall![1];
|
||||
};
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
const OLD = { tile_512: 583_000, step: 11_000 };
|
||||
@@ -1069,9 +1072,10 @@ describe('Cloudflare model-search pricing', () => {
|
||||
}),
|
||||
);
|
||||
expect(modelSearchFetch).toHaveBeenCalledOnce();
|
||||
expect(hasCreditsSpy).toHaveBeenCalledWith(
|
||||
expect(admitSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
4 * 5280 + 4 * 10560,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ vi.mock('../../inputImage.js', async (orig) => ({
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
let batchIncrementUsagesSpy: MockInstance<
|
||||
MeteringService['batchIncrementUsages']
|
||||
@@ -111,7 +111,7 @@ beforeEach(() => {
|
||||
generateContentMock.mockReset();
|
||||
toBase64DataUriMock.mockReset();
|
||||
googleAICtor.mockReset();
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
batchIncrementUsagesSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
@@ -167,7 +167,7 @@ describe('GeminiImageProvider.generate test_mode', () => {
|
||||
expect(result).toBe(
|
||||
'https://puter-sample-data.puter.site/image_example.png',
|
||||
);
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(generateContentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -219,7 +219,7 @@ describe('GeminiImageProvider.generate argument validation', () => {
|
||||
legacyCode: 'bad_request',
|
||||
message: expect.stringContaining('Unsupported quality tier'),
|
||||
});
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(generateContentMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
@@ -427,7 +427,7 @@ describe('GeminiImageProvider.generate Flash path (generateContent)', () => {
|
||||
|
||||
it('throws 402 BEFORE hitting Gemini when actor lacks credits', async () => {
|
||||
const provider = makeProvider();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
@@ -466,7 +466,7 @@ describe('GeminiImageProvider.generate Flash path (generateContent)', () => {
|
||||
actor,
|
||||
);
|
||||
|
||||
const [, estimate] = hasCreditsSpy.mock.calls[0]!;
|
||||
const [, estimate] = admitSpy.mock.calls[0]!;
|
||||
expect(estimate).toBeGreaterThan(0);
|
||||
expect(heldDuringCall).toBe(Math.ceil(estimate));
|
||||
// The hold stays until the usage write lands.
|
||||
@@ -643,7 +643,7 @@ describe('Gemini Nano Banana image generation', () => {
|
||||
const request = generateContentMock.mock.calls[0][0];
|
||||
expect(request.config.imageConfig.imageSize).toBe('1K');
|
||||
// Input + 1120 image tokens at $30/1M + 1000 text tokens at $7.50/1M.
|
||||
expect(hasCreditsSpy).toHaveBeenCalledWith(expect.anything(), 4_194_450);
|
||||
expect(admitSpy).toHaveBeenCalledWith(expect.anything(), 4_194_450, undefined);
|
||||
expect(batchIncrementUsagesSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.arrayContaining([
|
||||
|
||||
@@ -50,7 +50,7 @@ import { KlingImageProvider } from './KlingImageProvider.js';
|
||||
|
||||
let server: PuterServer;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -73,7 +73,7 @@ const makeProvider = () =>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
incrementUsageSpy.mockResolvedValue({} as never);
|
||||
});
|
||||
@@ -318,7 +318,7 @@ describe('KlingImageProvider billing', () => {
|
||||
});
|
||||
|
||||
it('rejects before calling Kling when credits run short', async () => {
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
await expect(generate({})).rejects.toMatchObject({ statusCode: 402 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ vi.mock('../../inputImage.js', async (orig) => ({
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let batchIncrementUsagesSpy: MockInstance<
|
||||
MeteringService['batchIncrementUsages']
|
||||
>;
|
||||
@@ -126,7 +126,7 @@ beforeEach(() => {
|
||||
editMock.mockReset();
|
||||
fetchImageAsBase64Mock.mockReset();
|
||||
openAICtor.mockReset();
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
batchIncrementUsagesSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'batchIncrementUsages',
|
||||
@@ -150,9 +150,9 @@ describe('OpenAiImageProvider construction', () => {
|
||||
// ── Model catalog ───────────────────────────────────────────────────
|
||||
|
||||
describe('OpenAiImageProvider model catalog', () => {
|
||||
it('returns gpt-image-2 as the default', () => {
|
||||
it('returns gpt-image-2.6-flare as the default', () => {
|
||||
const provider = makeProvider();
|
||||
expect(provider.getDefaultModel()).toBe('gpt-image-2');
|
||||
expect(provider.getDefaultModel()).toBe('gpt-image-2.6-flare');
|
||||
});
|
||||
|
||||
it('drops the deprecated gpt-image-1 family and chatgpt-image-latest', () => {
|
||||
@@ -195,7 +195,7 @@ describe('OpenAiImageProvider.generate test_mode', () => {
|
||||
expect(result).toBe(
|
||||
'https://puter-sample-data.puter.site/image_example.png',
|
||||
);
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -235,9 +235,60 @@ describe('OpenAiImageProvider.generate argument validation', () => {
|
||||
// ── Credit gate ─────────────────────────────────────────────────────
|
||||
|
||||
describe('OpenAiImageProvider.generate credit gate', () => {
|
||||
it('waits for the real usage write before returning an image', async () => {
|
||||
const actor = makeActor({
|
||||
user: {
|
||||
id: 62,
|
||||
uuid: `image-write-${Date.now()}`,
|
||||
username: 'metered',
|
||||
},
|
||||
});
|
||||
const redis = server.clients.redis as unknown as {
|
||||
meterIncr: (...args: string[]) => Promise<unknown>;
|
||||
};
|
||||
const realIncr = redis.meterIncr.bind(redis);
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let blocked = false;
|
||||
vi.spyOn(redis, 'meterIncr').mockImplementation(async (...args) => {
|
||||
const actorKey = `:actor:${actor.user!.uuid}:`;
|
||||
if (!blocked && args.some((arg) => arg.includes(actorKey))) {
|
||||
blocked = true;
|
||||
await held;
|
||||
}
|
||||
return realIncr(...args);
|
||||
});
|
||||
generateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://image.example/result.png' }],
|
||||
});
|
||||
const provider = makeProvider();
|
||||
let finished = false;
|
||||
const generating = withTestActor(
|
||||
() =>
|
||||
provider.generate({
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'an image',
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
}),
|
||||
actor,
|
||||
).then((result) => {
|
||||
finished = true;
|
||||
return result;
|
||||
});
|
||||
try {
|
||||
await vi.waitFor(() => expect(blocked).toBe(true));
|
||||
expect(finished).toBe(false);
|
||||
} finally {
|
||||
release();
|
||||
await generating;
|
||||
}
|
||||
});
|
||||
|
||||
it('throws 402 BEFORE hitting OpenAI when actor lacks credits', async () => {
|
||||
const provider = makeProvider();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.generate({
|
||||
@@ -273,7 +324,7 @@ describe('OpenAiImageProvider.generate credit gate', () => {
|
||||
actor,
|
||||
);
|
||||
|
||||
const [, estimate] = hasCreditsSpy.mock.calls[0]!;
|
||||
const [, estimate] = admitSpy.mock.calls[0]!;
|
||||
expect(estimate).toBeGreaterThan(0);
|
||||
expect(heldDuringCall).toBe(Math.ceil(estimate));
|
||||
// The hold stays until the usage write lands.
|
||||
@@ -677,8 +728,8 @@ describe('OpenAiImageProvider gpt-image-2.5 models', () => {
|
||||
);
|
||||
|
||||
// Latent factor 48 vs 24 at the medium tier.
|
||||
const gptImage2Estimate = hasCreditsSpy.mock.calls[0]![1];
|
||||
const gptImage25Estimate = hasCreditsSpy.mock.calls[1]![1];
|
||||
const gptImage2Estimate = admitSpy.mock.calls[0]![1];
|
||||
const gptImage25Estimate = admitSpy.mock.calls[1]![1];
|
||||
expect(gptImage25Estimate).toBeLessThan(gptImage2Estimate);
|
||||
});
|
||||
|
||||
@@ -703,8 +754,8 @@ describe('OpenAiImageProvider gpt-image-2.5 models', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const highEstimate = hasCreditsSpy.mock.calls[0]![1];
|
||||
const maxEstimate = hasCreditsSpy.mock.calls[1]![1];
|
||||
const highEstimate = admitSpy.mock.calls[0]![1];
|
||||
const maxEstimate = admitSpy.mock.calls[1]![1];
|
||||
expect(maxEstimate).toBeGreaterThan(highEstimate);
|
||||
});
|
||||
});
|
||||
@@ -857,3 +908,64 @@ it('keeps a landscape aspect hint landscape at gpt-image-2 sizes', async () => {
|
||||
expect(w).toBeGreaterThan(h);
|
||||
expect(Math.abs(w / h - 1.7)).toBeLessThan(0.1);
|
||||
});
|
||||
|
||||
|
||||
describe('OpenAiImageProvider GPT Image 2.6', () => {
|
||||
it.each(['gpt-image-2.6-flare', 'gpt-image-2.6-sunburst'])(
|
||||
'%s generates and edits with token-based billing', async (model) => {
|
||||
const provider = makeProvider();
|
||||
const response = {
|
||||
data: [{ b64_json: 'AAAA' }],
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 800,
|
||||
input_tokens_details: { text_tokens: 100, image_tokens: 0 },
|
||||
},
|
||||
};
|
||||
generateMock.mockResolvedValue(response);
|
||||
editMock.mockResolvedValue(response);
|
||||
for (const input_image of [undefined, 'data:image/png;base64,AAAA']) {
|
||||
const result = await withTestActor(() => provider.generate({
|
||||
model, prompt: 'hi', quality: 'xhigh',
|
||||
ratio: { w: 1024, h: 1024 }, input_image,
|
||||
}));
|
||||
expect(result).toBe('data:image/png;base64,AAAA');
|
||||
}
|
||||
for (const mock of [generateMock, editMock]) {
|
||||
expect(mock.mock.calls[0]![0]).toMatchObject({
|
||||
model, quality: 'xhigh', size: '1024x1024',
|
||||
});
|
||||
}
|
||||
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
|
||||
expect(entries).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
usageType: `openai:${model}:xhigh:1024x1024:output`,
|
||||
costOverride: 2_400_000,
|
||||
}),
|
||||
]));
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['low', 1024, 1024, 823],
|
||||
['medium', 1024, 1024, 1449],
|
||||
['high', 1024, 1024, 1756],
|
||||
['xhigh', 1024, 1024, 3122],
|
||||
['max', 1024, 1024, 7024],
|
||||
['auto', 1024, 1024, 1449],
|
||||
['low', 2048, 1088, 571],
|
||||
])('estimates %s at %sx%s from the 2.6 calculator', async (quality, w, h, tokens) => {
|
||||
generateMock.mockResolvedValue({ data: [{ b64_json: 'AAAA' }] });
|
||||
await withTestActor(() => makeProvider().generate({
|
||||
model: 'gpt-image-2.6-flare', prompt: 'hi', quality: String(quality),
|
||||
ratio: { w: Number(w), h: Number(h) },
|
||||
}));
|
||||
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
|
||||
const output = (entries as Array<{ usageType: string; costOverride: number }>)
|
||||
.find((entry) => entry.usageType.endsWith(':output'))!;
|
||||
expect(output.usageType).toBe(`openai:gpt-image-2.6-flare:${quality}:${w}x${h}:output`);
|
||||
// The metering conversion rounds fractional microcents upward.
|
||||
expect(output.costOverride).toBeGreaterThanOrEqual(Number(tokens) * 3000);
|
||||
expect(output.costOverride).toBeLessThanOrEqual(Number(tokens) * 3000 + 1);
|
||||
});
|
||||
});
|
||||
@@ -56,8 +56,8 @@ interface OpenAIImageUsage {
|
||||
|
||||
/**
|
||||
* OpenAI image generation provider for v2. Supports the GPT Image models
|
||||
* (gpt-image-2, -2.5-sunburst, -2.5-flare), including image-to-image editing
|
||||
* via `input_images` (the `images.edit` endpoint).
|
||||
* (gpt-image-2 and the 2.5/2.6 Sunburst and Flare models), including
|
||||
* image-to-image editing via `input_images` (the `images.edit` endpoint).
|
||||
*/
|
||||
export class OpenAiImageProvider implements IImageProvider {
|
||||
#meteringService: AiMeteringService;
|
||||
@@ -109,7 +109,7 @@ export class OpenAiImageProvider implements IImageProvider {
|
||||
}
|
||||
|
||||
getDefaultModel(): string {
|
||||
return 'gpt-image-2';
|
||||
return 'gpt-image-2.6-flare';
|
||||
}
|
||||
|
||||
async generate({
|
||||
@@ -293,7 +293,10 @@ export class OpenAiImageProvider implements IImageProvider {
|
||||
});
|
||||
}
|
||||
if (usageEntries.length) {
|
||||
this.#meteringService.batchIncrementUsages(actor, usageEntries);
|
||||
await this.#meteringService.batchIncrementUsages(
|
||||
actor,
|
||||
usageEntries,
|
||||
);
|
||||
}
|
||||
|
||||
const url =
|
||||
@@ -601,17 +604,36 @@ export class OpenAiImageProvider implements IImageProvider {
|
||||
height: number,
|
||||
quality?: string,
|
||||
): number {
|
||||
const factors = model.startsWith('gpt-image-2.5')
|
||||
? OpenAiImageProvider.#GPT_IMAGE_2_5_LATENT_FACTORS
|
||||
: OpenAiImageProvider.#GPT_IMAGE_2_LATENT_FACTORS;
|
||||
const isGptImage26 = model.startsWith('gpt-image-2.6');
|
||||
const factors =
|
||||
isGptImage26 || model.startsWith('gpt-image-2.5')
|
||||
? OpenAiImageProvider.#GPT_IMAGE_2_5_LATENT_FACTORS
|
||||
: OpenAiImageProvider.#GPT_IMAGE_2_LATENT_FACTORS;
|
||||
const factor = factors[quality ?? ''] ?? factors.medium;
|
||||
const longEdge = Math.max(width, height);
|
||||
const shortEdge = Math.min(width, height);
|
||||
const shortLatent = Math.round((factor * shortEdge) / longEdge);
|
||||
const shortGrid = (factor * shortEdge) / longEdge;
|
||||
const floorGrid = Math.floor(shortGrid);
|
||||
const shortLatent =
|
||||
isGptImage26 && shortGrid - floorGrid === 0.5
|
||||
? floorGrid + (floorGrid % 2)
|
||||
: Math.round(shortGrid);
|
||||
const latentW = width >= height ? factor : shortLatent;
|
||||
const latentH = width >= height ? shortLatent : factor;
|
||||
const baseArea = latentW * latentH;
|
||||
return Math.ceil((baseArea * (2_000_000 + width * height)) / 4_000_000);
|
||||
const tokens = Math.ceil(
|
||||
(baseArea * (2_000_000 + width * height)) / 4_000_000,
|
||||
);
|
||||
// GPT Image 2.6 bills more tokens at low and medium quality.
|
||||
const estimatedQuality =
|
||||
quality && quality in factors ? quality : 'medium';
|
||||
const multiplier =
|
||||
estimatedQuality === 'low'
|
||||
? 4.2
|
||||
: estimatedQuality === 'medium'
|
||||
? 3.3
|
||||
: 1;
|
||||
return isGptImage26 ? Math.round(tokens * multiplier) : tokens;
|
||||
}
|
||||
|
||||
#estimateOutputCostFromTokens(
|
||||
|
||||
@@ -20,6 +20,46 @@
|
||||
import type { IImageModel } from '../../types.js';
|
||||
|
||||
export const OPEN_AI_IMAGE_GENERATION_MODELS: IImageModel[] = [
|
||||
{
|
||||
puterId: 'openai:openai/gpt-image-2.6-sunburst',
|
||||
id: 'gpt-image-2.6-sunburst',
|
||||
aliases: ['openai/gpt-image-2.6-sunburst'],
|
||||
name: 'GPT Image 2.6 Sunburst',
|
||||
version: '2.6',
|
||||
costs_currency: 'usd-cents',
|
||||
index_cost_key: 'low:1024x1024',
|
||||
costs: {
|
||||
// Text tokens (per 1M tokens)
|
||||
text_input: 500, // $5.00
|
||||
text_cached_input: 125, // $1.25
|
||||
// Image tokens (per 1M tokens)
|
||||
image_input: 800, // $8.00
|
||||
image_cached_input: 200, // $2.00
|
||||
image_output: 3000, // $30.00
|
||||
'low:1024x1024': 2.469,
|
||||
},
|
||||
allowedQualityLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'auto'],
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-image-2.6-flare',
|
||||
id: 'gpt-image-2.6-flare',
|
||||
aliases: ['openai/gpt-image-2.6-flare'],
|
||||
name: 'GPT Image 2.6 Flare',
|
||||
version: '2.6',
|
||||
costs_currency: 'usd-cents',
|
||||
index_cost_key: 'low:1024x1024',
|
||||
costs: {
|
||||
// Text tokens (per 1M tokens)
|
||||
text_input: 500, // $5.00
|
||||
text_cached_input: 125, // $1.25
|
||||
// Image tokens (per 1M tokens)
|
||||
image_input: 800, // $8.00
|
||||
image_cached_input: 200, // $2.00
|
||||
image_output: 3000, // $30.00
|
||||
'low:1024x1024': 2.469,
|
||||
},
|
||||
allowedQualityLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'auto'],
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-image-2.5-sunburst',
|
||||
id: 'gpt-image-2.5-sunburst',
|
||||
|
||||
+14
-9
@@ -84,7 +84,7 @@ vi.mock('../../../../util/secureHttp.js', () => ({
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
let batchIncrementUsagesSpy: MockInstance<
|
||||
MeteringService['batchIncrementUsages']
|
||||
@@ -115,7 +115,7 @@ beforeEach(() => {
|
||||
cancelMock.mockReset();
|
||||
replicateCtor.mockReset();
|
||||
secureFetchMock.mockReset();
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
batchIncrementUsagesSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
@@ -177,7 +177,7 @@ describe('ReplicateImageGenerationProvider.generate test_mode', () => {
|
||||
expect(result).toBe(
|
||||
'https://puter-sample-data.puter.site/image_example.png',
|
||||
);
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(createPredictionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -199,7 +199,7 @@ describe('ReplicateImageGenerationProvider.generate argument validation', () =>
|
||||
describe('ReplicateImageGenerationProvider.generate credit gate', () => {
|
||||
it('throws 402 BEFORE hitting Replicate when actor lacks credits', async () => {
|
||||
const provider = makeProvider();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
await expect(
|
||||
withTestActor(() => provider.generate({ prompt: 'hi' })),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
@@ -255,7 +255,10 @@ describe('ReplicateImageGenerationProvider.generate credit gate', () => {
|
||||
it('holds credits for longer than a prediction can run', async () => {
|
||||
const provider = makeProvider();
|
||||
const actor = holdUser('ttl');
|
||||
const reserveSpy = vi.spyOn(server.services.metering, 'reserveCredits');
|
||||
const reserveSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'reserveCreditsIfAffordable',
|
||||
);
|
||||
createPredictionMock.mockResolvedValueOnce({
|
||||
id: 'ttl',
|
||||
status: 'succeeded',
|
||||
@@ -274,7 +277,9 @@ describe('ReplicateImageGenerationProvider.generate credit gate', () => {
|
||||
const provider = makeProvider();
|
||||
const actor = holdUser('surcharge');
|
||||
secureFetchMock.mockResolvedValueOnce(new Response('not-an-image'));
|
||||
hasCreditsSpy.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
||||
admitSpy.mockImplementationOnce((who, amount, opts) =>
|
||||
server.services.metering.reserveCredits(who, amount, opts),
|
||||
).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
withTestActor(
|
||||
@@ -950,7 +955,7 @@ it('does not start a prediction for an already-aborted request', async () => {
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'client_aborted' });
|
||||
expect(createPredictionMock).not.toHaveBeenCalled();
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops polling and cancels at the prediction deadline', async () => {
|
||||
@@ -1192,12 +1197,12 @@ it('rejects more than ten input images before fetching or pricing any', async ()
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
|
||||
expect(secureFetchMock).not.toHaveBeenCalled();
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(createPredictionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks credits on the output-only estimate before fetching input images', async () => {
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
|
||||
@@ -71,7 +71,7 @@ vi.mock('together-ai', () => {
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -96,7 +96,7 @@ const makeProvider = () =>
|
||||
beforeEach(() => {
|
||||
generateMock.mockReset();
|
||||
togetherCtor.mockReset();
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
});
|
||||
|
||||
@@ -158,7 +158,7 @@ describe('TogetherImageProvider.generate test_mode', () => {
|
||||
expect(result).toBe(
|
||||
'https://puter-sample-data.puter.site/image_example.png',
|
||||
);
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -189,7 +189,7 @@ describe('TogetherImageProvider.generate argument validation', () => {
|
||||
describe('TogetherImageProvider.generate credit gate', () => {
|
||||
it('throws 402 BEFORE hitting Together when actor lacks credits', async () => {
|
||||
const provider = makeProvider();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
@@ -760,7 +760,7 @@ describe('Together supported sizes and references', () => {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
});
|
||||
expect(Number.isFinite(hasCreditsSpy.mock.calls[0][1])).toBe(true);
|
||||
expect(Number.isFinite(admitSpy.mock.calls[0][1])).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves provider aliases without changing the selected model', async () => {
|
||||
@@ -790,7 +790,7 @@ describe('Together supported sizes and references', () => {
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -859,7 +859,7 @@ it.each(['3K', 'unknown', ' 5k '])(
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
@@ -895,7 +895,7 @@ describe('Together current API prices', () => {
|
||||
input_image: 'https://example.com/reference.png',
|
||||
}),
|
||||
);
|
||||
expect(hasCreditsSpy).toHaveBeenCalledWith(expect.anything(), 4685000);
|
||||
expect(admitSpy).toHaveBeenCalledWith(expect.anything(), 4685000, undefined);
|
||||
expect(incrementUsageSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'togetherai:google/flash-image-3.1:0.5K',
|
||||
|
||||
@@ -85,7 +85,7 @@ vi.mock('openai', () => {
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let batchIncrementUsagesSpy: MockInstance<
|
||||
MeteringService['batchIncrementUsages']
|
||||
>;
|
||||
@@ -115,7 +115,7 @@ beforeEach(() => {
|
||||
openAICtor.mockReset();
|
||||
axiosGetMock.mockReset();
|
||||
axiosGetMock.mockRejectedValue(new Error('Catalog unavailable'));
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
batchIncrementUsagesSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'batchIncrementUsages',
|
||||
@@ -182,7 +182,7 @@ describe('XAIImageProvider.generate test_mode', () => {
|
||||
expect(result).toBe(
|
||||
'https://puter-sample-data.puter.site/image_example.png',
|
||||
);
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -231,7 +231,7 @@ describe('XAIImageProvider.generate argument validation', () => {
|
||||
describe('XAIImageProvider.generate credit gate', () => {
|
||||
it('throws 402 BEFORE hitting xAI when actor lacks credits', async () => {
|
||||
const provider = makeProvider();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
@@ -718,7 +718,7 @@ describe('XAIImageProvider quality and reference limits', () => {
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(postMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -746,7 +746,7 @@ describe('xAI option validation', () => {
|
||||
makeProvider().generate({ prompt: 'hi', ...options } as never),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,7 +167,7 @@ const sentRegions = () =>
|
||||
|
||||
let server: PuterServer;
|
||||
let driver: OCRDriver;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -199,7 +199,7 @@ beforeEach(() => {
|
||||
mistralCtor.mockReset();
|
||||
// Spy on metering — keep the real impl so its recording side runs,
|
||||
// but capture calls so per-test assertions can inspect them.
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
});
|
||||
|
||||
@@ -335,7 +335,7 @@ describe('OCRDriver.recognize (aws-textract)', () => {
|
||||
};
|
||||
|
||||
it('throws 402 when the actor does not have enough credits', async () => {
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
const { actor } = await makeUser();
|
||||
|
||||
await expect(
|
||||
@@ -546,7 +546,7 @@ describe('OCRDriver.recognize (aws-textract)', () => {
|
||||
describe('regional pricing', () => {
|
||||
const onePage = { Blocks: [{ BlockType: 'PAGE' }] };
|
||||
const checkedCosts = () =>
|
||||
hasCreditsSpy.mock.calls.map(([, cost]) => cost);
|
||||
admitSpy.mock.calls.map(([, cost]) => cost);
|
||||
const textractCharges = () =>
|
||||
incrementUsageSpy.mock.calls
|
||||
.filter(([, type]) => type === TEXTRACT_USAGE)
|
||||
@@ -719,7 +719,7 @@ describe('OCRDriver.recognize (mistral)', () => {
|
||||
};
|
||||
|
||||
it('throws 402 when the actor does not have enough credits', async () => {
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
const { actor } = await makeUser();
|
||||
|
||||
await expect(
|
||||
@@ -1033,7 +1033,7 @@ describe('OCRDriver.recognize (mistral)', () => {
|
||||
const pageType = 'mistral-ocr:mistral-ocr-4-1:page';
|
||||
const annotationType = 'mistral-ocr:mistral-ocr-4-1:annotations:page';
|
||||
// The pre-flight covers the page plus its annotation.
|
||||
expect(hasCreditsSpy.mock.calls[0]![1]).toBe(
|
||||
expect(admitSpy.mock.calls[0]![1]).toBe(
|
||||
OCR_COSTS[pageType] + OCR_COSTS[annotationType],
|
||||
);
|
||||
const ocrCalls = incrementUsageSpy.mock.calls.filter(
|
||||
@@ -1080,7 +1080,7 @@ describe('OCRDriver credit pre-flight', () => {
|
||||
const pageType = 'mistral-ocr:mistral-ocr-4-1:page';
|
||||
const perPage = OCR_COSTS[pageType];
|
||||
const pdfSource = (buffer: Buffer) => dataUrl(buffer, 'application/pdf');
|
||||
const checkedCosts = () => hasCreditsSpy.mock.calls.map(([, cost]) => cost);
|
||||
const checkedCosts = () => admitSpy.mock.calls.map(([, cost]) => cost);
|
||||
const outstandingHolds = (actor: Actor) =>
|
||||
server.stores.creditHold.outstanding(actor.user.uuid!);
|
||||
|
||||
@@ -1171,8 +1171,8 @@ describe('OCRDriver credit pre-flight', () => {
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
|
||||
// The estimate/hold path (which calls hasEnoughCredits) never ran.
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
// The estimate/hold path never ran.
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(mistralOcrProcessMock).not.toHaveBeenCalled();
|
||||
noUsage.mockRestore();
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ vi.mock('undici', async (importOriginal) => ({
|
||||
let server: PuterServer;
|
||||
let driver: VoiceChangerDriver;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -91,7 +91,7 @@ afterAll(async () => {
|
||||
beforeEach(() => {
|
||||
webFetchMock.mockReset();
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
});
|
||||
|
||||
@@ -197,7 +197,7 @@ describe('VoiceChangerDriver.convert argument validation', () => {
|
||||
describe('VoiceChangerDriver.convert credit gate', () => {
|
||||
it('throws 402 BEFORE hitting ElevenLabs when the actor lacks credits', async () => {
|
||||
const { actor } = await makeUser();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
@@ -387,7 +387,7 @@ describe('VoiceChangerDriver.convert success path', () => {
|
||||
|
||||
const [base, factored] = metered();
|
||||
expect(factored).toBe(base! * 2);
|
||||
const held = hasCreditsSpy.mock.calls.map(([, amount]) => amount);
|
||||
const held = admitSpy.mock.calls.map(([, amount]) => amount);
|
||||
expect(held).toEqual([base, base! * 2]);
|
||||
});
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ vi.mock('openai', () => {
|
||||
|
||||
let server: PuterServer;
|
||||
let driver: SpeechToTextDriver;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -145,7 +145,7 @@ beforeEach(() => {
|
||||
toFileMock.mockClear();
|
||||
// Spy on metering — keep the real impl so its recording side runs,
|
||||
// but capture calls so per-test assertions can inspect them.
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
});
|
||||
|
||||
@@ -360,7 +360,7 @@ describe('SpeechToTextDriver argument validation', () => {
|
||||
|
||||
describe('SpeechToTextDriver credit gate', () => {
|
||||
it('throws 402 BEFORE hitting OpenAI when actor lacks credits', async () => {
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
const { actor } = await makeUser();
|
||||
|
||||
await expect(
|
||||
@@ -686,7 +686,7 @@ describe('SpeechToTextDriver metering', () => {
|
||||
expect(cost).toBe(SPEECH_TO_TEXT_COSTS[usageType]);
|
||||
});
|
||||
|
||||
it('asks hasEnoughCredits for the same total it later meters', async () => {
|
||||
it('reserves the same total it later meters', async () => {
|
||||
const { actor } = await makeUser();
|
||||
transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' });
|
||||
|
||||
@@ -700,7 +700,7 @@ describe('SpeechToTextDriver metering', () => {
|
||||
|
||||
const usageType = 'openai:gpt-transcribe:second';
|
||||
const expected = SPEECH_TO_TEXT_COSTS[usageType] * 2;
|
||||
const creditCall = hasCreditsSpy.mock.calls[0]!;
|
||||
const creditCall = admitSpy.mock.calls[0]!;
|
||||
expect(creditCall[1]).toBe(expected);
|
||||
});
|
||||
|
||||
@@ -794,7 +794,7 @@ describe('SpeechToTextDriver metering', () => {
|
||||
|
||||
const [base, factored] = metered();
|
||||
expect(factored).toBe(base! * 2);
|
||||
const held = hasCreditsSpy.mock.calls.map(([, amount]) => amount);
|
||||
const held = admitSpy.mock.calls.map(([, amount]) => amount);
|
||||
expect(held).toEqual([base, base! * 2]);
|
||||
});
|
||||
});
|
||||
|
||||
+10
-7
@@ -72,7 +72,7 @@ const HOLD = COUNTED_TOKENS * AUDIO_RATE + 8192 * OUTPUT_RATE;
|
||||
let server: PuterServer;
|
||||
let sttDriver: SpeechToTextDriver;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let batchSpy: MockInstance<MeteringService['batchIncrementUsages']>;
|
||||
|
||||
const driver = {
|
||||
@@ -102,7 +102,7 @@ beforeEach(() => {
|
||||
.mockRejectedValue(new Error('unexpected fetch')) as MockInstance<
|
||||
typeof fetch
|
||||
>;
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
batchSpy = vi.spyOn(server.services.metering, 'batchIncrementUsages');
|
||||
});
|
||||
|
||||
@@ -220,13 +220,16 @@ const metered = (): UsageInput[] =>
|
||||
/** Records each credit hold taken, its options, and its release. */
|
||||
const trackHolds = (order: string[] = []) => {
|
||||
const metering = server.services.metering;
|
||||
const realReserve = metering.reserveCredits.bind(metering);
|
||||
const realReserve = Object.getPrototypeOf(
|
||||
metering,
|
||||
).reserveCreditsIfAffordable.bind(metering) as typeof metering.reserveCreditsIfAffordable;
|
||||
const held: { amount: number; ttlMs?: number }[] = [];
|
||||
const released = vi.fn();
|
||||
vi.spyOn(metering, 'reserveCredits').mockImplementation(
|
||||
vi.spyOn(metering, 'reserveCreditsIfAffordable').mockImplementation(
|
||||
async (actor, amount, opts) => {
|
||||
held.push({ amount, ttlMs: opts?.ttlMs });
|
||||
const hold = await realReserve(actor, amount, opts);
|
||||
if (!hold) return null;
|
||||
return {
|
||||
...hold,
|
||||
release: async () => {
|
||||
@@ -425,7 +428,7 @@ describe('GeminiSpeechToTextProvider test_mode', () => {
|
||||
model: MODEL,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1263,12 +1266,12 @@ describe('GeminiSpeechToTextProvider countTokens preflight', () => {
|
||||
describe('GeminiSpeechToTextProvider credit hold', () => {
|
||||
it('throws 402 after counting tokens, before transcribing', async () => {
|
||||
const { actor } = await makeUser();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
fetchSpy.mockResolvedValueOnce(Response.json({ totalTokens: 91 }));
|
||||
await expect(
|
||||
withActor(actor, () => driver.transcribe({ file: dataUrl() })),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
expect(hasCreditsSpy.mock.calls[0]![1]).toBe(HOLD);
|
||||
expect(admitSpy.mock.calls[0]![1]).toBe(HOLD);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ const driver = {
|
||||
getReportedCosts: () => sttDriver.getReportedCosts(),
|
||||
};
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -88,7 +88,7 @@ afterAll(async () => {
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
});
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('XAISpeechToTextProvider test_mode', () => {
|
||||
expect(Array.isArray(result.words)).toBe(true);
|
||||
// No file required, no actor required, no fetch / metering hit.
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -258,7 +258,7 @@ describe('XAISpeechToTextProvider missing API key', () => {
|
||||
|
||||
describe('XAISpeechToTextProvider credit gate', () => {
|
||||
it('throws 402 BEFORE hitting xAI when actor lacks credits', async () => {
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
const { actor } = await makeUser();
|
||||
|
||||
await expect(
|
||||
@@ -273,7 +273,7 @@ describe('XAISpeechToTextProvider credit gate', () => {
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks hasEnoughCredits for estimated seconds × per-second ucents', async () => {
|
||||
it('reserves estimated seconds × per-second ucents', async () => {
|
||||
const { actor } = await makeUser();
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
sttResponse({ text: 'ok', duration: 1 }),
|
||||
@@ -285,7 +285,7 @@ describe('XAISpeechToTextProvider credit gate', () => {
|
||||
driver.transcribe({ file: dataUrl(audio, 'audio/mp3') }),
|
||||
);
|
||||
|
||||
expect(hasCreditsSpy.mock.calls[0]![1]).toBe(UCENTS_PER_SECOND * 2);
|
||||
expect(admitSpy.mock.calls[0]![1]).toBe(UCENTS_PER_SECOND * 2);
|
||||
});
|
||||
|
||||
it('estimates 60 seconds for URL inputs (we cannot inspect size locally)', async () => {
|
||||
@@ -298,7 +298,7 @@ describe('XAISpeechToTextProvider credit gate', () => {
|
||||
driver.transcribe({ file: 'https://example.com/clip.mp3' }),
|
||||
);
|
||||
|
||||
expect(hasCreditsSpy.mock.calls[0]![1]).toBe(UCENTS_PER_SECOND * 60);
|
||||
expect(admitSpy.mock.calls[0]![1]).toBe(UCENTS_PER_SECOND * 60);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -699,9 +699,9 @@ describe('TTSDriver cost factor', () => {
|
||||
server.services.metering,
|
||||
'incrementUsage',
|
||||
);
|
||||
const hasCreditsSpy = vi.spyOn(
|
||||
const admitSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'hasEnoughCredits',
|
||||
'reserveCreditsIfAffordable',
|
||||
);
|
||||
const metered = () =>
|
||||
incrementUsageSpy.mock.calls.map(([, , , cost]) => cost!);
|
||||
@@ -725,7 +725,7 @@ describe('TTSDriver cost factor', () => {
|
||||
const [base, factored] = metered();
|
||||
expect(base).toBeGreaterThan(0);
|
||||
expect(factored).toBe(Math.round(base! * 2));
|
||||
const held = hasCreditsSpy.mock.calls.map(([, amount]) => amount);
|
||||
const held = admitSpy.mock.calls.map(([, amount]) => amount);
|
||||
expect(held).toEqual([base, Math.round(base! * 2)]);
|
||||
});
|
||||
});
|
||||
@@ -84,7 +84,7 @@ vi.mock('@aws-sdk/client-polly', async () => {
|
||||
|
||||
let server: PuterServer;
|
||||
let priceFetch: MockInstance<typeof fetch>;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -180,7 +180,7 @@ beforeEach(() => {
|
||||
pollyCtor.mockReset();
|
||||
priceFetch = vi.fn(servePriceLists());
|
||||
vi.stubGlobal('fetch', priceFetch);
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
});
|
||||
|
||||
@@ -314,7 +314,7 @@ describe('AWSPollyTTSProvider.synthesize test_mode', () => {
|
||||
url: 'https://puter-sample-data.puter.site/tts_example.mp3',
|
||||
content_type: 'audio',
|
||||
});
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(pollySendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -349,7 +349,7 @@ describe('AWSPollyTTSProvider.synthesize argument validation', () => {
|
||||
describe('AWSPollyTTSProvider.synthesize credit gate', () => {
|
||||
it('throws 402 BEFORE hitting Polly when actor lacks credits', async () => {
|
||||
const provider = makeProvider();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
sendDispatch();
|
||||
|
||||
await expect(
|
||||
@@ -481,7 +481,7 @@ describe('AWSPollyTTSProvider.synthesize metering', () => {
|
||||
expect(cost).toBe(expectedCost);
|
||||
});
|
||||
|
||||
it('asks for hasEnoughCredits with the same total it later meters', async () => {
|
||||
it('reserves the same total it later meters', async () => {
|
||||
const provider = makeProvider();
|
||||
sendDispatch();
|
||||
|
||||
@@ -495,7 +495,7 @@ describe('AWSPollyTTSProvider.synthesize metering', () => {
|
||||
);
|
||||
|
||||
const expectedCost = AWS_POLLY_COSTS['generative'] * text.length;
|
||||
expect(hasCreditsSpy.mock.calls[0]![1]).toBe(expectedCost);
|
||||
expect(admitSpy.mock.calls[0]![1]).toBe(expectedCost);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -599,7 +599,7 @@ describe('AWSPollyTTSProvider published pricing', () => {
|
||||
engine: 'generative',
|
||||
});
|
||||
|
||||
expect(hasCreditsSpy.mock.calls.map((c) => c[1])).toEqual([
|
||||
expect(admitSpy.mock.calls.map((c) => c[1])).toEqual([
|
||||
1700 * 5,
|
||||
3000 * 5,
|
||||
]);
|
||||
@@ -648,7 +648,7 @@ describe('AWSPollyTTSProvider published pricing', () => {
|
||||
await readStreamResult(result);
|
||||
|
||||
expect(refreshedNeural).toBe(19);
|
||||
expect(hasCreditsSpy.mock.calls[0]![1]).toBe(1700 * 5);
|
||||
expect(admitSpy.mock.calls[0]![1]).toBe(1700 * 5);
|
||||
expect(incrementUsageSpy.mock.calls[0]![3]).toBe(1700 * 5);
|
||||
});
|
||||
|
||||
|
||||
@@ -86,8 +86,10 @@ describe('meterPerCharacter', () => {
|
||||
|
||||
it('holds and then meters exactly text length × price', async () => {
|
||||
const check = vi
|
||||
.spyOn(server.services.metering, 'hasEnoughCredits')
|
||||
.mockResolvedValue(true);
|
||||
.spyOn(server.services.metering, 'reserveCreditsIfAffordable')
|
||||
.mockImplementation((who, amount, opts) =>
|
||||
server.services.metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
const record = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
|
||||
await drain(
|
||||
@@ -101,7 +103,7 @@ describe('meterPerCharacter', () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect(check).toHaveBeenCalledWith(actor, 7500);
|
||||
expect(check).toHaveBeenCalledWith(actor, 7500, undefined);
|
||||
expect(record).toHaveBeenCalledWith(
|
||||
actor,
|
||||
'openai:tts-1:character',
|
||||
@@ -118,8 +120,10 @@ describe('meterPerCharacter', () => {
|
||||
it('meters nothing when the upstream breaks before any audio', async () => {
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'hasEnoughCredits',
|
||||
).mockResolvedValue(true);
|
||||
'reserveCreditsIfAffordable',
|
||||
).mockImplementation((who, amount, opts) =>
|
||||
server.services.metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
const record = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
|
||||
await drain(
|
||||
@@ -139,8 +143,10 @@ describe('meterPerCharacter', () => {
|
||||
it('meters the full text once audio has started, even if the upstream then breaks', async () => {
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'hasEnoughCredits',
|
||||
).mockResolvedValue(true);
|
||||
'reserveCreditsIfAffordable',
|
||||
).mockImplementation((who, amount, opts) =>
|
||||
server.services.metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
const record = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
|
||||
await drain(
|
||||
@@ -165,8 +171,10 @@ describe('meterPerCharacter', () => {
|
||||
it('meters the full text when the caller hangs up before any audio', async () => {
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'hasEnoughCredits',
|
||||
).mockResolvedValue(true);
|
||||
'reserveCreditsIfAffordable',
|
||||
).mockImplementation((who, amount, opts) =>
|
||||
server.services.metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
const record = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
|
||||
const result = await meterPerCharacter(
|
||||
@@ -192,8 +200,10 @@ describe('meterPerCharacter', () => {
|
||||
it('meters nothing when synthesis fails', async () => {
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'hasEnoughCredits',
|
||||
).mockResolvedValue(true);
|
||||
'reserveCreditsIfAffordable',
|
||||
).mockImplementation((who, amount, opts) =>
|
||||
server.services.metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
const record = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
const boom = new Error('vendor down');
|
||||
|
||||
@@ -213,8 +223,8 @@ describe('meterPerCharacter', () => {
|
||||
it('refuses with a 402 before synthesizing when unaffordable', async () => {
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'hasEnoughCredits',
|
||||
).mockResolvedValue(false);
|
||||
'reserveCreditsIfAffordable',
|
||||
).mockResolvedValue(null);
|
||||
const synthesize = vi.fn(audio);
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -57,7 +57,7 @@ import { ELEVENLABS_TTS_COSTS } from './costs.js';
|
||||
|
||||
let server: PuterServer;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -88,7 +88,7 @@ const audioResponse = (body = 'audio-bytes', contentType = 'audio/mpeg') =>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
});
|
||||
|
||||
@@ -283,7 +283,7 @@ describe('ElevenLabsTTSProvider.synthesize test_mode', () => {
|
||||
url: 'https://puter-sample-data.puter.site/tts_example.mp3',
|
||||
content_type: 'audio',
|
||||
});
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -323,7 +323,7 @@ describe('ElevenLabsTTSProvider.synthesize argument validation', () => {
|
||||
});
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -358,7 +358,7 @@ describe('ElevenLabsTTSProvider.synthesize argument validation', () => {
|
||||
describe('ElevenLabsTTSProvider.synthesize credit gate', () => {
|
||||
it('throws 402 BEFORE hitting ElevenLabs when actor lacks credits', async () => {
|
||||
const provider = makeProvider();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
withTestActor(() => provider.synthesize({ text: 'hi' })),
|
||||
@@ -510,7 +510,7 @@ describe('ElevenLabsTTSProvider.synthesize request shape', () => {
|
||||
fields: { key, got: value },
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
@@ -893,7 +893,7 @@ describe('ElevenLabsTTSProvider.synthesize metering', () => {
|
||||
expect(cost).toBe(expectedCost);
|
||||
});
|
||||
|
||||
it('asks for hasEnoughCredits with the same total it later meters', async () => {
|
||||
it('reserves the same total it later meters', async () => {
|
||||
const provider = makeProvider();
|
||||
fetchSpy.mockResolvedValueOnce(audioResponse());
|
||||
|
||||
@@ -904,7 +904,7 @@ describe('ElevenLabsTTSProvider.synthesize metering', () => {
|
||||
|
||||
const expectedCost =
|
||||
ELEVENLABS_TTS_COSTS['eleven_multilingual_v2'] * text.length;
|
||||
expect(hasCreditsSpy.mock.calls[0]![1]).toBe(expectedCost);
|
||||
expect(admitSpy.mock.calls[0]![1]).toBe(expectedCost);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ const INTERACTIONS_URL =
|
||||
|
||||
let server: PuterServer;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let batchIncrementUsagesSpy: MockInstance<
|
||||
MeteringService['batchIncrementUsages']
|
||||
>;
|
||||
@@ -183,13 +183,16 @@ const payingUser = () =>
|
||||
/** Spies on the credit holds taken, recording the amount and each release. */
|
||||
const trackHolds = () => {
|
||||
const metering = server.services.metering;
|
||||
const realReserve = metering.reserveCredits.bind(metering);
|
||||
const realReserve = Object.getPrototypeOf(
|
||||
metering,
|
||||
).reserveCreditsIfAffordable.bind(metering) as typeof metering.reserveCreditsIfAffordable;
|
||||
const held: number[] = [];
|
||||
const released = vi.fn();
|
||||
vi.spyOn(metering, 'reserveCredits').mockImplementation(
|
||||
vi.spyOn(metering, 'reserveCreditsIfAffordable').mockImplementation(
|
||||
async (actor, amount, opts) => {
|
||||
held.push(amount);
|
||||
const hold = await realReserve(actor, amount, opts);
|
||||
if (!hold) return null;
|
||||
return {
|
||||
...hold,
|
||||
release: async () => {
|
||||
@@ -214,7 +217,7 @@ beforeEach(() => {
|
||||
.mockRejectedValue(new Error('unexpected fetch')) as MockInstance<
|
||||
typeof fetch
|
||||
>;
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
batchIncrementUsagesSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
'batchIncrementUsages',
|
||||
@@ -332,7 +335,7 @@ describe('GeminiTTSProvider.synthesize test_mode', () => {
|
||||
url: 'https://puter-sample-data.puter.site/tts_example.mp3',
|
||||
content_type: 'audio',
|
||||
});
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -406,7 +409,7 @@ describe('GeminiTTSProvider.synthesize argument validation', () => {
|
||||
legacyCode: 'input_too_large',
|
||||
fields: { key: 'text', max: 3000 },
|
||||
});
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -434,7 +437,7 @@ describe('GeminiTTSProvider.synthesize argument validation', () => {
|
||||
legacyCode: 'input_too_large',
|
||||
fields: { key: 'instructions', max: 3000 },
|
||||
});
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -443,7 +446,7 @@ describe('GeminiTTSProvider.synthesize argument validation', () => {
|
||||
|
||||
describe('GeminiTTSProvider.synthesize credit gate', () => {
|
||||
it('throws 402 before calling Gemini when the actor lacks credits', async () => {
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
await expect(
|
||||
withTestActor(() => makeProvider().synthesize({ text: 'hi' })),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
@@ -473,9 +476,10 @@ describe('GeminiTTSProvider.synthesize credit gate', () => {
|
||||
actor,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
expect(hasCreditsSpy).toHaveBeenCalledWith(
|
||||
expect(admitSpy).toHaveBeenCalledWith(
|
||||
actor,
|
||||
FLASH_RESERVATION,
|
||||
undefined,
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
|
||||
@@ -1365,9 +1369,10 @@ describe('GeminiTTSProvider.synthesize scheduled rates', () => {
|
||||
server.clients.event.off('ai.cost.factor.*', factor);
|
||||
}
|
||||
|
||||
expect(hasCreditsSpy).toHaveBeenCalledWith(
|
||||
expect(admitSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
reserved * 1.5,
|
||||
undefined,
|
||||
);
|
||||
expect(held).toEqual([reserved * 1.5]);
|
||||
expect(metered(':input').costOverride).toBe(7 * inputRate * 1.5);
|
||||
|
||||
@@ -84,7 +84,7 @@ vi.mock('openai', () => {
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -108,7 +108,7 @@ const mockAudioResponse = (bytes = 'opus-audio') => new Response(bytes);
|
||||
beforeEach(() => {
|
||||
speechCreateMock.mockReset();
|
||||
openAICtor.mockReset();
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
});
|
||||
|
||||
@@ -205,7 +205,7 @@ describe('OpenAITTSProvider.synthesize test_mode', () => {
|
||||
url: 'https://puter-sample-data.puter.site/tts_example.mp3',
|
||||
content_type: 'audio',
|
||||
});
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(speechCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -283,7 +283,7 @@ describe('OpenAITTSProvider.synthesize argument validation', () => {
|
||||
describe('OpenAITTSProvider.synthesize credit gate', () => {
|
||||
it('throws 402 BEFORE hitting OpenAI when actor lacks credits', async () => {
|
||||
const provider = makeProvider();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
withTestActor(() => provider.synthesize({ text: 'hi' })),
|
||||
@@ -298,7 +298,9 @@ describe('OpenAITTSProvider.synthesize credit gate', () => {
|
||||
user: { id: 7, uuid: `tts-hold-${Date.now()}`, username: 'hold' },
|
||||
});
|
||||
const userId = actor.user!.uuid;
|
||||
hasCreditsSpy.mockResolvedValue(true);
|
||||
admitSpy.mockImplementation((who, amount, opts) =>
|
||||
server.services.metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
let heldDuringCall = -1;
|
||||
speechCreateMock.mockImplementationOnce(async () => {
|
||||
heldDuringCall = await server.stores.creditHold.outstanding(userId);
|
||||
@@ -324,7 +326,9 @@ describe('OpenAITTSProvider.synthesize credit gate', () => {
|
||||
const actor = makeActor({
|
||||
user: { id: 8, uuid: `tts-fail-${Date.now()}`, username: 'fail' },
|
||||
});
|
||||
hasCreditsSpy.mockResolvedValue(true);
|
||||
admitSpy.mockImplementation((who, amount, opts) =>
|
||||
server.services.metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
speechCreateMock.mockRejectedValueOnce(new Error('upstream down'));
|
||||
|
||||
await expect(
|
||||
@@ -463,7 +467,7 @@ describe('OpenAITTSProvider.synthesize metering', () => {
|
||||
expect(cost).toBe(expectedCost);
|
||||
});
|
||||
|
||||
it('asks for hasEnoughCredits with the same total it later meters', async () => {
|
||||
it('reserves the same total it later meters', async () => {
|
||||
const provider = makeProvider();
|
||||
speechCreateMock.mockResolvedValueOnce(mockAudioResponse());
|
||||
|
||||
@@ -473,8 +477,8 @@ describe('OpenAITTSProvider.synthesize metering', () => {
|
||||
);
|
||||
|
||||
const expectedCost = OPENAI_TTS_COSTS['tts-1'] * text.length;
|
||||
// First call to hasEnoughCredits should match the metered cost.
|
||||
const creditCall = hasCreditsSpy.mock.calls[0]!;
|
||||
// The first reservation should match the metered cost.
|
||||
const creditCall = admitSpy.mock.calls[0]!;
|
||||
expect(creditCall[1]).toBe(expectedCost);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ import { SPEECHIFY_TTS_COSTS } from './costs.js';
|
||||
|
||||
let server: PuterServer;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -82,7 +82,7 @@ const audioResponse = (audioData = Buffer.from('audio-bytes').toString('base64')
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
});
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('SpeechifyTTSProvider.synthesize test_mode', () => {
|
||||
url: 'https://puter-sample-data.puter.site/tts_example.mp3',
|
||||
content_type: 'audio',
|
||||
});
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -206,7 +206,7 @@ describe('SpeechifyTTSProvider.synthesize argument validation', () => {
|
||||
describe('SpeechifyTTSProvider.synthesize credit gate', () => {
|
||||
it('throws 402 BEFORE hitting Speechify when actor lacks credits', async () => {
|
||||
const provider = makeProvider();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
withTestActor(() => provider.synthesize({ text: 'hi' })),
|
||||
@@ -324,7 +324,7 @@ describe('SpeechifyTTSProvider.synthesize metering', () => {
|
||||
expect(cost).toBe(expectedCost);
|
||||
});
|
||||
|
||||
it('asks for hasEnoughCredits with the same total it later meters', async () => {
|
||||
it('reserves the same total it later meters', async () => {
|
||||
const provider = makeProvider();
|
||||
fetchSpy.mockResolvedValueOnce(audioResponse());
|
||||
|
||||
@@ -332,7 +332,7 @@ describe('SpeechifyTTSProvider.synthesize metering', () => {
|
||||
await withTestActor(() => provider.synthesize({ text }));
|
||||
|
||||
const expectedCost = SPEECHIFY_TTS_COSTS['simba-3.2'] * text.length;
|
||||
expect(hasCreditsSpy.mock.calls[0]![1]).toBe(expectedCost);
|
||||
expect(admitSpy.mock.calls[0]![1]).toBe(expectedCost);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ import { XAI_TTS_COSTS } from './costs.js';
|
||||
|
||||
let server: PuterServer;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -79,7 +79,7 @@ const audioResponse = (body = 'audio-bytes', contentType = 'audio/mpeg') =>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
});
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('XAITTSProvider.synthesize test_mode', () => {
|
||||
url: 'https://puter-sample-data.puter.site/tts_example.mp3',
|
||||
content_type: 'audio',
|
||||
});
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -195,7 +195,7 @@ describe('XAITTSProvider.synthesize argument validation', () => {
|
||||
describe('XAITTSProvider.synthesize credit gate', () => {
|
||||
it('throws 402 BEFORE hitting xAI when actor lacks credits', async () => {
|
||||
const provider = makeProvider();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
withTestActor(() => provider.synthesize({ text: 'hi' })),
|
||||
@@ -327,7 +327,7 @@ describe('XAITTSProvider.synthesize metering', () => {
|
||||
expect(cost).toBe(expectedCost);
|
||||
});
|
||||
|
||||
it('asks for hasEnoughCredits with the same total it later meters', async () => {
|
||||
it('reserves the same total it later meters', async () => {
|
||||
const provider = makeProvider();
|
||||
fetchSpy.mockResolvedValueOnce(audioResponse());
|
||||
|
||||
@@ -335,7 +335,7 @@ describe('XAITTSProvider.synthesize metering', () => {
|
||||
await withTestActor(() => provider.synthesize({ text }));
|
||||
|
||||
const expectedCost = XAI_TTS_COSTS['xai-tts'] * text.length;
|
||||
expect(hasCreditsSpy.mock.calls[0]![1]).toBe(expectedCost);
|
||||
expect(admitSpy.mock.calls[0]![1]).toBe(expectedCost);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ vi.mock('../../util/secureHttp.js', async (importOriginal) => ({
|
||||
|
||||
let server: PuterServer;
|
||||
let driver: VideoGenerationDriver;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer({
|
||||
@@ -122,8 +122,10 @@ beforeEach(() => {
|
||||
togetherVideosCreateMock.mockReset();
|
||||
togetherVideosRetrieveMock.mockReset();
|
||||
secureFetchMock.mockReset();
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
hasCreditsSpy.mockResolvedValue(true);
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
admitSpy.mockImplementation((who, amount, opts) =>
|
||||
server.services.metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
vi.spyOn(server.services.metering, 'getRemainingUsage').mockResolvedValue(
|
||||
100_000_000_000,
|
||||
);
|
||||
|
||||
@@ -106,7 +106,7 @@ const MP4 = Buffer.concat([
|
||||
let server: PuterServer;
|
||||
let videoDriver: VideoGenerationDriver;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let batchSpy: MockInstance<MeteringService['batchIncrementUsages']>;
|
||||
|
||||
const generate = (args: Record<string, unknown> = {}) =>
|
||||
@@ -139,9 +139,11 @@ beforeEach(() => {
|
||||
secureFetchMock.mockReset();
|
||||
secureFetchMock.mockRejectedValue(new Error('unexpected secureFetch'));
|
||||
// The full hold is more than a new account's allowance.
|
||||
hasCreditsSpy = vi
|
||||
.spyOn(server.services.metering, 'hasEnoughCredits')
|
||||
.mockResolvedValue(true);
|
||||
admitSpy = vi
|
||||
.spyOn(server.services.metering, 'reserveCreditsIfAffordable')
|
||||
.mockImplementation((who, amount, opts) =>
|
||||
server.services.metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
batchSpy = vi.spyOn(server.services.metering, 'batchIncrementUsages');
|
||||
});
|
||||
|
||||
@@ -589,7 +591,7 @@ describe('GeminiVideoProvider test_mode', () => {
|
||||
it('returns the sample clip without network, credits or metering', async () => {
|
||||
expect(await testMode()).toBe(SAMPLE_URL);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1028,7 +1030,7 @@ describe('GeminiVideoProvider source video', () => {
|
||||
|
||||
const path = await writeVideo(user, 'dog.mp4', MP4);
|
||||
const read = vi.spyOn(server.stores.s3Object, 'getObjectStream');
|
||||
hasCreditsSpy.mockResolvedValue(false);
|
||||
admitSpy.mockResolvedValue(null);
|
||||
countOnly();
|
||||
await expect(
|
||||
withActor(user.actor, () => generate({ input_video: path })),
|
||||
@@ -1573,12 +1575,12 @@ describe('GeminiVideoProvider countTokens preflight', () => {
|
||||
describe('GeminiVideoProvider credit hold', () => {
|
||||
it('throws 402 after counting tokens, before generating', async () => {
|
||||
const { actor } = await makeUser();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
fetchSpy.mockResolvedValueOnce(Response.json({ totalTokens: 18 }));
|
||||
await expect(withActor(actor, () => generate())).rejects.toMatchObject({
|
||||
statusCode: 402,
|
||||
});
|
||||
expect(hasCreditsSpy.mock.calls[0]![1]).toBe(HOLD);
|
||||
expect(admitSpy.mock.calls[0]![1]).toBe(HOLD);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ vi.mock('together-ai', () => {
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let admitSpy: MockInstance<MeteringService['reserveCreditsIfAffordable']>;
|
||||
let remainingUsageSpy: MockInstance<MeteringService['getRemainingUsage']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
@@ -117,11 +117,13 @@ beforeEach(() => {
|
||||
videosCreateMock.mockReset();
|
||||
videosRetrieveMock.mockReset();
|
||||
togetherCtor.mockReset();
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
admitSpy = vi.spyOn(server.services.metering, 'reserveCreditsIfAffordable');
|
||||
// Default to "has credits" so per-test setup only needs to override
|
||||
// for the explicit credit-gate scenarios. SYSTEM_ACTOR has no uuid,
|
||||
// so the live `getRemainingUsage` path can short-circuit to 0.
|
||||
hasCreditsSpy.mockResolvedValue(true);
|
||||
admitSpy.mockImplementation((who, amount, opts) =>
|
||||
server.services.metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
remainingUsageSpy = vi.spyOn(server.services.metering, 'getRemainingUsage');
|
||||
remainingUsageSpy.mockResolvedValue(AMPLE_CREDIT);
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
@@ -197,7 +199,7 @@ describe('TogetherVideoProvider.generate test_mode', () => {
|
||||
provider.generate({ prompt: 'hi', test_mode: true }),
|
||||
);
|
||||
expect(result).toBe('https://assets.puter.site/txt2vid.mp4');
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(videosCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -238,7 +240,7 @@ const DIRECTOR = 'togetherai:minimax/video-01-director';
|
||||
describe('TogetherVideoProvider.generate credit gate', () => {
|
||||
it('throws 402 BEFORE hitting Together when actor lacks credits', async () => {
|
||||
const provider = makeProvider();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
admitSpy.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
@@ -715,7 +717,7 @@ describe('TogetherVideoProvider.generate per-second models', () => {
|
||||
expect('height' in sent).toBe(false);
|
||||
expect('ratio' in sent).toBe(false);
|
||||
// The flat-rate gate is not consulted for per-second models.
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
|
||||
const seedance = TOGETHER_VIDEO_GENERATION_MODELS.find(
|
||||
(m) => m.model === 'ByteDance/Seedance-2.5',
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
normalizeRawBase64ImageString,
|
||||
validateIconDataUrl,
|
||||
} from '../../util/appIcon.js';
|
||||
import { toAppReadView } from '../../util/appView.js';
|
||||
import { isOriginBootstrapApp, toAppReadView } from '../../util/appView.js';
|
||||
import { awaitLater } from '../../util/concurrency.js';
|
||||
import { isUniqueViolation } from '../../util/dbError.js';
|
||||
import {
|
||||
@@ -1088,22 +1088,6 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin-bootstrap detection: rows auto-created when an unknown origin
|
||||
* first needed an app row (no human-supplied metadata). Marker is `name ===
|
||||
* uid && title === uid` and a description starting with "App created from
|
||||
* origin ". Only these rows are eligible for same-owner merging — refusing
|
||||
* to merge arbitrary same-owner apps prevents accidental data loss.
|
||||
*/
|
||||
#isOriginBootstrapApp(app) {
|
||||
if (!app || typeof app !== 'object') return false;
|
||||
if (typeof app.uid !== 'string' || !app.uid) return false;
|
||||
if (app.name !== app.uid) return false;
|
||||
if (app.title !== app.uid) return false;
|
||||
if (typeof app.description !== 'string') return false;
|
||||
return app.description.startsWith('App created from origin ');
|
||||
}
|
||||
|
||||
// -- Canonical-uid alias kvstore pair -----------------------------
|
||||
//
|
||||
// After a merge, the source app uid → canonical uid mapping is
|
||||
@@ -1266,7 +1250,7 @@ export class AppDriver extends PuterDriver {
|
||||
if (
|
||||
Number.isInteger(conflictOwnerUserId) &&
|
||||
conflictOwnerUserId === user.id &&
|
||||
!this.#isOriginBootstrapApp(appToJoin)
|
||||
!isOriginBootstrapApp(appToJoin)
|
||||
) {
|
||||
// Prevent merging arbitrary same-owner apps; only allow the
|
||||
// auto-created origin bootstrap row to be absorbed.
|
||||
|
||||
@@ -188,7 +188,11 @@ describe('app.message with live delivery over the socket wire', () => {
|
||||
captureBus(state.server, 'outer.gui.notif.message', async () => {
|
||||
created = (await withActor(asApp(holder, app), () =>
|
||||
state.driver.create(
|
||||
message({ text: 'Body', channel: 'replies' }),
|
||||
message({
|
||||
text: 'Body',
|
||||
priority: 'high',
|
||||
channel: 'replies',
|
||||
}),
|
||||
),
|
||||
)) as Record<string, unknown>;
|
||||
}).then((seen) => (desktop = seen)),
|
||||
@@ -200,7 +204,7 @@ describe('app.message with live delivery over the socket wire', () => {
|
||||
title: 'Hello',
|
||||
text: 'Body',
|
||||
channel: 'replies',
|
||||
priority: 'normal',
|
||||
priority: 'high',
|
||||
ttl: 3600,
|
||||
app: { uid: app.uid, name: 'App message test' },
|
||||
});
|
||||
@@ -212,6 +216,7 @@ describe('app.message with live delivery over the socket wire', () => {
|
||||
app_uid: app.uid,
|
||||
audience: 'app-user',
|
||||
type: 'app.message',
|
||||
value: expect.objectContaining({ priority: 'high' }),
|
||||
});
|
||||
// Not silent, but sent for the app alone: the desktop wire stays quiet.
|
||||
expect(written).toEqual([
|
||||
@@ -220,7 +225,7 @@ describe('app.message with live delivery over the socket wire', () => {
|
||||
uid,
|
||||
type: 'app.message',
|
||||
appUid: app.uid,
|
||||
}),
|
||||
value: expect.objectContaining({ priority: 'high' }), }),
|
||||
]);
|
||||
expect(desktop).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -207,6 +207,7 @@ describe('app.message to the other party of a share', () => {
|
||||
const written = await captureBus('notif.written', async () => {
|
||||
sent = await send(inApp(owner, app), {
|
||||
title: 'New message',
|
||||
priority: 'high',
|
||||
to: { handle },
|
||||
});
|
||||
});
|
||||
@@ -222,7 +223,7 @@ describe('app.message to the other party of a share', () => {
|
||||
expect(row!.value).toEqual({
|
||||
type: 'app.message',
|
||||
title: 'New message',
|
||||
priority: 'normal',
|
||||
priority: 'high',
|
||||
ttl: 3600,
|
||||
app: { uid: app.uid, name: 'Handle App' },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `notify({ sendAt })` without and with `services.schedules`, and the deferred
|
||||
* mailbox write an extension runs inside its own transaction, fenced on a guard
|
||||
* in a table of its own (a test table here).
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import type { BatchEntry } from '../../clients/database/DatabaseClient.js';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { isHttpError } from '../../core/http/HttpError.js';
|
||||
import type {
|
||||
DeferredSendPlan,
|
||||
ScheduleServiceContract,
|
||||
} from '../../core/schedules.js';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import { EVENTS_BACKGROUND_PERMISSION } from '../../services/events/authorization.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import type {
|
||||
DeferredWriteTx,
|
||||
NotificationDriver,
|
||||
} from './NotificationDriver.js';
|
||||
|
||||
vi.setConfig({ testTimeout: 120_000 });
|
||||
|
||||
const BOOT_TIMEOUT_MS = 480_000;
|
||||
const TEST_TABLE = 'deferred_send_test';
|
||||
|
||||
let server: PuterServer;
|
||||
let holder: Actor;
|
||||
let app: { id: number; uid: string };
|
||||
let saved: unknown;
|
||||
let nextId = 1;
|
||||
|
||||
const db = () => server.clients.db;
|
||||
const driver = () =>
|
||||
server.drivers.notifications as unknown as NotificationDriver;
|
||||
const slot = () => server.services as unknown as { schedules?: unknown };
|
||||
/** Put `schedules` in the slot, or leave the slot empty. */
|
||||
const setSchedules = (schedules: unknown): void => {
|
||||
if (schedules === undefined) delete slot().schedules;
|
||||
else slot().schedules = schedules;
|
||||
};
|
||||
const asApp = (): Actor => makeActor({ user: holder.user, app });
|
||||
|
||||
const notify = (fields: Record<string, unknown>): Promise<unknown> =>
|
||||
runWithContext({ actor: asApp() }, () =>
|
||||
driver().create({
|
||||
object: { value: { type: 'app.message', title: 'Hi', ...fields } },
|
||||
}),
|
||||
);
|
||||
|
||||
const codeOf = async (run: Promise<unknown>): Promise<string | undefined> => {
|
||||
try {
|
||||
await run;
|
||||
} catch (err) {
|
||||
if (isHttpError(err)) return err.legacyCode;
|
||||
throw err;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const mailbox = () =>
|
||||
db().read(
|
||||
'SELECT * FROM `notification` WHERE `user_id` = ? AND `app_uid` = ? ' +
|
||||
'ORDER BY `id`',
|
||||
[holder.user!.id, app.uid],
|
||||
);
|
||||
|
||||
/** A row's value: JSON text on sqlite, parsed on the other engines. */
|
||||
const valueOf = (row: Record<string, unknown>) =>
|
||||
typeof row.value === 'string' ? JSON.parse(row.value) : row.value;
|
||||
|
||||
/** A send claimed under `token`, as an extension's own row would be. */
|
||||
const claimed = async (token: string): Promise<number> => {
|
||||
const id = nextId++;
|
||||
await db().write(
|
||||
`INSERT INTO \`${TEST_TABLE}\` (\`id\`, \`token\`, \`state\`) VALUES (?, ?, 'sending')`,
|
||||
[id, token],
|
||||
);
|
||||
return id;
|
||||
};
|
||||
|
||||
/** The transaction an extension wraps the write in: its row, then the mailbox. */
|
||||
const txFor = (id: number, token: string): DeferredWriteTx => ({
|
||||
guard: {
|
||||
table: TEST_TABLE,
|
||||
where: "`id` = ? AND `token` = ? AND `state` = 'delivered'",
|
||||
values: [id, token],
|
||||
},
|
||||
async commit(entries: readonly BatchEntry[]) {
|
||||
await db().batchWrite([
|
||||
{
|
||||
statement:
|
||||
`UPDATE \`${TEST_TABLE}\` SET \`state\` = 'delivered' ` +
|
||||
"WHERE `id` = ? AND `token` = ? AND `state` = 'sending'",
|
||||
values: [id, token],
|
||||
},
|
||||
...entries,
|
||||
]);
|
||||
const [row] = await db().pread(
|
||||
`SELECT \`state\`, \`token\` FROM \`${TEST_TABLE}\` WHERE \`id\` = ?`,
|
||||
[id],
|
||||
);
|
||||
return row?.state === 'delivered' && row.token === token;
|
||||
},
|
||||
});
|
||||
|
||||
const write = (
|
||||
sendKey: string,
|
||||
tx: DeferredWriteTx,
|
||||
over: {
|
||||
payload?: string | null;
|
||||
appUid?: string;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
) =>
|
||||
driver().deferredAppMessages.write(
|
||||
{
|
||||
holderUserId: holder.user!.id,
|
||||
appUid: over.appUid ?? app.uid,
|
||||
payload:
|
||||
over.payload === undefined
|
||||
? JSON.stringify({ message: { title: 'Later' } })
|
||||
: over.payload,
|
||||
sendKey,
|
||||
signal: over.signal ?? new AbortController().signal,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
const fakeSchedules = (plan: Partial<DeferredSendPlan>) =>
|
||||
({
|
||||
onSubscriptionsChanged: vi.fn(async () => {}),
|
||||
createFor: vi.fn(),
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
receivePeerChanges: vi.fn(),
|
||||
prepareSendAt: vi.fn(async (): Promise<DeferredSendPlan> => ({
|
||||
sendAt: 0,
|
||||
immediate: false,
|
||||
holderUserId: holder.user!.id,
|
||||
appUid: app.uid,
|
||||
handlerDepth: 0,
|
||||
quota: {
|
||||
recurringPerUser: 1,
|
||||
recurringPerApp: 1,
|
||||
oneShotsPerUser: 1,
|
||||
oneShotsPerApp: 1,
|
||||
budgetPerUser: 1,
|
||||
budgetPerApp: 1,
|
||||
},
|
||||
...plan,
|
||||
})),
|
||||
createDeferredAppMessage: vi.fn(async (p: DeferredSendPlan) => ({
|
||||
scheduleId: 'sched-1',
|
||||
sendAt: p.sendAt,
|
||||
})),
|
||||
}) satisfies ScheduleServiceContract;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer({
|
||||
events: { enabled: true },
|
||||
} as IConfig);
|
||||
const username = `sendat-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const user = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
const row = (await server.stores.user.getById(user.id))!;
|
||||
holder = makeActor({
|
||||
user: {
|
||||
id: row.id,
|
||||
uuid: row.uuid,
|
||||
username: row.username,
|
||||
email: row.email ?? null,
|
||||
email_confirmed: true,
|
||||
} as Actor['user'],
|
||||
});
|
||||
const name = `sendat-app-${Math.random().toString(36).slice(2, 10)}`;
|
||||
app = (await server.stores.app.create(
|
||||
{ name, title: name, index_url: `https://${name}.test/` },
|
||||
{ ownerUserId: row.id },
|
||||
)) as { id: number; uid: string };
|
||||
await server.services.permission.grantUserAppPermission(
|
||||
holder,
|
||||
app.uid,
|
||||
EVENTS_BACKGROUND_PERMISSION,
|
||||
);
|
||||
await db().write(
|
||||
`CREATE TABLE \`${TEST_TABLE}\` (\`id\` INTEGER PRIMARY KEY, ` +
|
||||
'`token` VARCHAR(64) NOT NULL, `state` VARCHAR(16) NOT NULL)',
|
||||
[],
|
||||
);
|
||||
saved = slot().schedules;
|
||||
}, BOOT_TIMEOUT_MS);
|
||||
|
||||
beforeEach(async () => {
|
||||
await db().write('DELETE FROM `notification` WHERE `app_uid` = ?', [
|
||||
app.uid,
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setSchedules(saved);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
describe('notify({ sendAt }) without services.schedules', () => {
|
||||
it('is refused events_disabled, even when due now, storing nothing', async () => {
|
||||
setSchedules(undefined);
|
||||
expect(await codeOf(notify({ sendAt: Date.now() }))).toBe(
|
||||
'events_disabled',
|
||||
);
|
||||
expect(await codeOf(notify({ sendAt: Date.now() + 3_600_000 }))).toBe(
|
||||
'events_disabled',
|
||||
);
|
||||
expect(await mailbox()).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends as before without sendAt', async () => {
|
||||
setSchedules(undefined);
|
||||
const sent = (await notify({ text: 'now' })) as { uid: string };
|
||||
expect(sent.uid).toBeTruthy();
|
||||
expect(await mailbox()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notify({ sendAt }) with services.schedules', () => {
|
||||
it('hands a later send to createDeferredAppMessage and answers its id', async () => {
|
||||
const at = Date.now() + 3_600_000;
|
||||
const schedules = fakeSchedules({ sendAt: at });
|
||||
setSchedules(schedules);
|
||||
|
||||
await expect(
|
||||
notify({ sendAt: at, text: 'later', priority: 'high' }),
|
||||
).resolves.toEqual({
|
||||
scheduled: { scheduleId: 'sched-1', sendAt: at },
|
||||
});
|
||||
expect(schedules.prepareSendAt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user: holder.user }),
|
||||
at,
|
||||
);
|
||||
expect(schedules.createDeferredAppMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sendAt: at }),
|
||||
{
|
||||
message: expect.objectContaining({
|
||||
title: 'Hi',
|
||||
text: 'later',
|
||||
priority: 'high',
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(await mailbox()).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends at once when the plan says it is due', async () => {
|
||||
const schedules = fakeSchedules({ immediate: true });
|
||||
setSchedules(schedules);
|
||||
|
||||
const sent = (await notify({ sendAt: Date.now() })) as {
|
||||
uid: string;
|
||||
};
|
||||
expect(sent.uid).toBeTruthy();
|
||||
expect(schedules.createDeferredAppMessage).not.toHaveBeenCalled();
|
||||
expect(await mailbox()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('passes a refusal from prepareSendAt through', async () => {
|
||||
const schedules = fakeSchedules({});
|
||||
schedules.prepareSendAt.mockRejectedValueOnce(
|
||||
Object.assign(new Error('scoped'), {}),
|
||||
);
|
||||
setSchedules(schedules);
|
||||
await expect(notify({ sendAt: Date.now() })).rejects.toThrow('scoped');
|
||||
expect(await mailbox()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deferredAppMessages.write', () => {
|
||||
it('writes one row keyed by the send, in the caller`s transaction', async () => {
|
||||
const token = uuidv4();
|
||||
const key = uuidv4();
|
||||
const id = await claimed(token);
|
||||
|
||||
await expect(
|
||||
write(key, txFor(id, token), {
|
||||
payload: JSON.stringify({
|
||||
message: { title: 'Later', priority: 'high' },
|
||||
}),
|
||||
}),
|
||||
).resolves.toBe('delivered');
|
||||
const rows = await mailbox();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ uid: key, send_key: key });
|
||||
expect(valueOf(rows[0])).toMatchObject({
|
||||
title: 'Later',
|
||||
priority: 'high',
|
||||
});
|
||||
});
|
||||
|
||||
it('writes nothing when the guard no longer holds: another claim took it', async () => {
|
||||
const id = await claimed(uuidv4());
|
||||
const key = uuidv4();
|
||||
|
||||
await expect(write(key, txFor(id, uuidv4()))).resolves.toBe('lost');
|
||||
expect(await mailbox()).toEqual([]);
|
||||
});
|
||||
|
||||
it('replaces the open row of its group, which takes the key', async () => {
|
||||
const open = (await notify({ group: 'g', text: 'first' })) as {
|
||||
uid: string;
|
||||
};
|
||||
const token = uuidv4();
|
||||
const key = uuidv4();
|
||||
const id = await claimed(token);
|
||||
|
||||
await expect(
|
||||
write(key, txFor(id, token), {
|
||||
payload: JSON.stringify({
|
||||
message: { title: 'Later', group: 'g' },
|
||||
}),
|
||||
}),
|
||||
).resolves.toBe('delivered');
|
||||
const rows = await mailbox();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ uid: open.uid, send_key: key });
|
||||
expect(valueOf(rows[0])).toMatchObject({ title: 'Later' });
|
||||
});
|
||||
|
||||
it('drops a payload it can`t read, and a sender whose app is gone', async () => {
|
||||
const token = uuidv4();
|
||||
const id = await claimed(token);
|
||||
await expect(
|
||||
write(uuidv4(), txFor(id, token), { payload: '{"nope":1}' }),
|
||||
).resolves.toBe('dropped');
|
||||
await expect(
|
||||
write(uuidv4(), txFor(id, token), { appUid: `app-${uuidv4()}` }),
|
||||
).resolves.toBe('dropped');
|
||||
expect(await mailbox()).toEqual([]);
|
||||
});
|
||||
|
||||
it('writes nothing once the caller`s claim is lost', async () => {
|
||||
const token = uuidv4();
|
||||
const id = await claimed(token);
|
||||
const lost = new AbortController();
|
||||
lost.abort();
|
||||
|
||||
await expect(
|
||||
write(uuidv4(), txFor(id, token), { signal: lost.signal }),
|
||||
).rejects.toThrow();
|
||||
expect(await mailbox()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A second claim taking a send over while the first one's transaction is open.
|
||||
* Needs two writers at once with row locks; sqlite serialises writers, so this
|
||||
* runs on postgres only.
|
||||
*/
|
||||
describe.runIf(process.env.PUTER_TEST_DB_ENGINE === 'postgres')(
|
||||
'a deferred write racing another writer',
|
||||
() => {
|
||||
/**
|
||||
* Hold the first statement matching `match` on its connection, before
|
||||
* or after it runs, until released.
|
||||
*/
|
||||
const holdStatement = (match: RegExp, when: 'before' | 'after') => {
|
||||
const client = db() as unknown as {
|
||||
query: (
|
||||
target: unknown,
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
const original = client.query.bind(client);
|
||||
let reached!: () => void;
|
||||
let release!: () => void;
|
||||
const atBarrier = new Promise<void>((r) => (reached = r));
|
||||
const released = new Promise<void>((r) => (release = r));
|
||||
let held = false;
|
||||
vi.spyOn(client as never, 'query' as never).mockImplementation(
|
||||
(async (target: unknown, sql: string, params?: unknown[]) => {
|
||||
if (held || !match.test(sql))
|
||||
return original(target, sql, params);
|
||||
held = true;
|
||||
if (when === 'before') {
|
||||
reached();
|
||||
await released;
|
||||
return original(target, sql, params);
|
||||
}
|
||||
const result = await original(target, sql, params);
|
||||
reached();
|
||||
await released;
|
||||
return result;
|
||||
}) as never,
|
||||
);
|
||||
return { atBarrier, release };
|
||||
};
|
||||
|
||||
const DELIVERED = /SET `state` = 'delivered'/;
|
||||
const REPLACE = /`replaced_at` = \?, `send_key` = \? WHERE/;
|
||||
|
||||
/** Whether `promise` is still pending after `ms`. */
|
||||
const stillPending = async (promise: Promise<unknown>, ms: number) => {
|
||||
let settled = false;
|
||||
const watched = (async () => {
|
||||
try {
|
||||
await promise;
|
||||
} finally {
|
||||
settled = true;
|
||||
}
|
||||
})();
|
||||
await new Promise((r) => setTimeout(r, ms));
|
||||
void watched;
|
||||
return !settled;
|
||||
};
|
||||
|
||||
it('a transaction that opened before a takeover writes nothing', async () => {
|
||||
const stale = uuidv4();
|
||||
const id = await claimed(stale);
|
||||
const key = uuidv4();
|
||||
const gate = holdStatement(DELIVERED, 'before');
|
||||
const first = write(key, txFor(id, stale));
|
||||
await gate.atBarrier;
|
||||
|
||||
await db().write(
|
||||
`UPDATE \`${TEST_TABLE}\` SET \`token\` = ? WHERE \`id\` = ?`,
|
||||
[uuidv4(), id],
|
||||
);
|
||||
gate.release();
|
||||
await expect(first).resolves.toBe('lost');
|
||||
expect(
|
||||
await server.stores.notification.getBySendKey(key),
|
||||
).toBeNull();
|
||||
expect(await mailbox()).toEqual([]);
|
||||
});
|
||||
|
||||
it('an immediate replacement waiting on the open transaction lands after it, without the key', async () => {
|
||||
const open = (await notify({ group: 'g', text: 'first' })) as {
|
||||
uid: string;
|
||||
};
|
||||
const token = uuidv4();
|
||||
const id = await claimed(token);
|
||||
const key = uuidv4();
|
||||
const gate = holdStatement(REPLACE, 'after');
|
||||
const deferred = write(key, txFor(id, token), {
|
||||
payload: JSON.stringify({
|
||||
message: { title: 'Later', group: 'g', text: 'later' },
|
||||
}),
|
||||
});
|
||||
await gate.atBarrier;
|
||||
|
||||
const immediate = notify({ group: 'g', text: 'newer' });
|
||||
// Past the group lock's wait, then blocked on the row.
|
||||
expect(await stillPending(immediate, 2_500)).toBe(true);
|
||||
gate.release();
|
||||
await expect(deferred).resolves.toBe('delivered');
|
||||
await immediate;
|
||||
|
||||
const rows = await mailbox();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ uid: open.uid, send_key: null });
|
||||
expect(valueOf(rows[0]).text).toBe('newer');
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -859,3 +859,32 @@ describe('NotificationDriver — rows written before the scope columns', () => {
|
||||
expect(await selectUids(holder.actor)).toEqual([uid]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('NotificationDriver displayed notification catch-up', () => {
|
||||
it('selects named ids within the app and user scope', async () => {
|
||||
const box = await mailbox();
|
||||
await server.services.notification.markAcknowledged(box.rows.appUser, box.holder.userId);
|
||||
const rows = await withActor(box.ownAppActor, () => driver.select({
|
||||
subject: 'notif:app-user',
|
||||
uids: Object.values(box.rows),
|
||||
limit: 50,
|
||||
}));
|
||||
expect(rows).toEqual([expect.objectContaining({
|
||||
uid: box.rows.appUser, acknowledged: expect.any(Number),
|
||||
})]);
|
||||
});
|
||||
|
||||
it.each([Array(51).fill('one'), [''], [7], 'one', ['x'.repeat(129)]])(
|
||||
'rejects an invalid or oversized id batch: %s', async (uids) => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(withActor(actor, () => driver.select({ uids })))
|
||||
.rejects.toMatchObject({ statusCode: 400 });
|
||||
},
|
||||
);
|
||||
|
||||
it('returns no rows for an empty id batch', async () => {
|
||||
const { actor } = await makeUser();
|
||||
expect(await withActor(actor, () => driver.select({ uids: [] }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -46,11 +46,19 @@ import {
|
||||
ownedAppUids,
|
||||
} from '../../services/notification/notificationAudience.js';
|
||||
import {
|
||||
NOTIFICATION_VALUE_MAX_BYTES,
|
||||
assertNotificationValueFits,
|
||||
OUTSIDE_ACCOUNT_MAILBOX_TYPES,
|
||||
resolveNotificationWrite,
|
||||
type NotificationTypeName,
|
||||
} from '../../services/notification/notificationTypes.js';
|
||||
import {
|
||||
schedulesUnavailable,
|
||||
type DeferredAppMessage,
|
||||
type DeferredSendPlan,
|
||||
type ScheduleServiceContract,
|
||||
type SqlGuard,
|
||||
} from '../../core/schedules.js';
|
||||
import type { BatchEntry } from '../../clients/database/DatabaseClient.js';
|
||||
import { withRedisLock } from '../../util/redisLock.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
@@ -67,6 +75,44 @@ const GROUP_LOCK_RETRY_MS = 25;
|
||||
|
||||
type AppMessageWindow = 'minute' | 'day';
|
||||
|
||||
export type { SqlGuard };
|
||||
|
||||
/** What a delayed app message's fire-time write needs from its row. */
|
||||
export interface DeferredWriteInput {
|
||||
holderUserId: number;
|
||||
appUid: string;
|
||||
/** The stored {@link DeferredAppMessage}, as JSON text. */
|
||||
payload: string | null;
|
||||
/** The slot's key: the written row's uid or `send_key`, and its `sendId`. */
|
||||
sendKey: string;
|
||||
/** Aborted when the caller's claim is lost. */
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's transaction the mailbox write commits in. Atomicity is the
|
||||
* caller's to keep; the type can't enforce it.
|
||||
*/
|
||||
export interface DeferredWriteTx {
|
||||
/**
|
||||
* What every mailbox statement is fenced on: the caller's row marked
|
||||
* delivered by this claim.
|
||||
*/
|
||||
guard: SqlGuard;
|
||||
/**
|
||||
* Must be the whole delivery in one step: the caller's guarded transition
|
||||
* of its row to delivered and `entries`, in that order, in one
|
||||
* `batchWrite`, then a read of the row on the primary. True only when that
|
||||
* read shows this claim holding the row as delivered. Splitting the
|
||||
* transition from `entries` lets one slot write two rows.
|
||||
*/
|
||||
commit(entries: readonly BatchEntry[]): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** How a delayed send's write stood when it stopped. */
|
||||
export type DeferredWriteOutcome =
|
||||
'delivered' | 'dropped' | 'lost' | 'senderSuspended';
|
||||
|
||||
/** The mailbox slice a call named, in the terms the query takes it. */
|
||||
interface MailboxScope {
|
||||
audiences: readonly string[];
|
||||
@@ -74,19 +120,6 @@ interface MailboxScope {
|
||||
appUid: string | null | undefined;
|
||||
}
|
||||
|
||||
const assertValueFits = (value: Record<string, unknown>): void => {
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(value), 'utf8') >
|
||||
NOTIFICATION_VALUE_MAX_BYTES
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`\`value\` may not exceed ${NOTIFICATION_VALUE_MAX_BYTES} bytes`,
|
||||
{ legacyCode: 'notification_value_too_large' },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const typeNotAllowed = (message: string): HttpError =>
|
||||
new HttpError(403, message, {
|
||||
legacyCode: 'forbidden',
|
||||
@@ -124,6 +157,21 @@ const parseNotifyTarget = (to: unknown): string => {
|
||||
return target.handle;
|
||||
};
|
||||
|
||||
/** A stored delayed message, or `null` for one that can't be read back. */
|
||||
const parseDelayedAppMessage = (
|
||||
payload: string | null,
|
||||
): DeferredAppMessage | null => {
|
||||
try {
|
||||
const stored = JSON.parse(payload ?? 'null') as DeferredAppMessage;
|
||||
if (typeof stored?.message?.title !== 'string') return null;
|
||||
if (stored.to !== undefined && typeof stored.to !== 'string')
|
||||
return null;
|
||||
return stored;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether a row belongs to the slice, as the same SQL scope selected it. */
|
||||
const inScope = (
|
||||
row: Record<string, unknown>,
|
||||
@@ -216,7 +264,7 @@ export class NotificationDriver extends PuterDriver {
|
||||
value,
|
||||
);
|
||||
}
|
||||
assertValueFits(value);
|
||||
assertNotificationValueFits(value);
|
||||
if (type === APP_MESSAGE_TYPE) {
|
||||
throw typeNotAllowed(`Only apps can create ${APP_MESSAGE_TYPE}`);
|
||||
}
|
||||
@@ -281,13 +329,34 @@ export class NotificationDriver extends PuterDriver {
|
||||
: predicate;
|
||||
|
||||
const scope = this.#resolveScope(actor, args.subject);
|
||||
const uids = args.uids;
|
||||
if (
|
||||
uids !== undefined &&
|
||||
(!Array.isArray(uids) ||
|
||||
uids.length > 50 ||
|
||||
uids.some(
|
||||
(uid) =>
|
||||
typeof uid !== 'string' ||
|
||||
uid.length === 0 ||
|
||||
uid.length > 128,
|
||||
))
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'uids must contain at most 50 notification ids',
|
||||
{
|
||||
legacyCode: 'bad_request',
|
||||
},
|
||||
);
|
||||
}
|
||||
const query: {
|
||||
limit: number;
|
||||
scope: MailboxScope | null;
|
||||
filter?: string;
|
||||
onlyUnacknowledged?: boolean;
|
||||
excludeTypes?: readonly string[];
|
||||
} = { limit, scope };
|
||||
uids?: string[];
|
||||
} = { limit, scope, ...(uids ? { uids: uids as string[] } : {}) };
|
||||
// The whole mailbox, or a slice naming no app, is the desktop's view of
|
||||
// it, which leaves out the types only an app has a use for. A slice
|
||||
// naming the app still returns them.
|
||||
@@ -354,7 +423,7 @@ export class NotificationDriver extends PuterDriver {
|
||||
object: Record<string, unknown>,
|
||||
value: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const { type, to, ...fields } = value;
|
||||
const { type, to, sendAt, ...fields } = value;
|
||||
if (type !== APP_MESSAGE_TYPE) {
|
||||
throw typeNotAllowed(`Apps can create only ${APP_MESSAGE_TYPE}`);
|
||||
}
|
||||
@@ -369,7 +438,13 @@ export class NotificationDriver extends PuterDriver {
|
||||
}
|
||||
const handle = to === undefined ? undefined : parseNotifyTarget(to);
|
||||
const message = parseAppMessage(fields);
|
||||
assertValueFits(value);
|
||||
assertNotificationValueFits(value);
|
||||
const timing =
|
||||
sendAt === undefined || sendAt === null
|
||||
? null
|
||||
: await this.#schedules().prepareSendAt(actor, sendAt);
|
||||
if (timing && !timing.immediate)
|
||||
return this.#scheduleAppMessage(actor, message, handle, timing);
|
||||
|
||||
if (handle === undefined) {
|
||||
const uid = await this.#sendAppMessage(
|
||||
@@ -410,12 +485,23 @@ export class NotificationDriver extends PuterDriver {
|
||||
appUid: string,
|
||||
message: AppMessage,
|
||||
): Promise<string> {
|
||||
const send = () =>
|
||||
this.#replaceOrWriteAppMessage(recipientId, appUid, message);
|
||||
return this.#oneSendPerGroup(recipientId, appUid, message, () =>
|
||||
this.#replaceOrWriteAppMessage(recipientId, appUid, message),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One send per group at a time, so a burst into an empty group writes one
|
||||
* row that the rest replace. A lock that is busy past the wait or
|
||||
* unreachable still sends: the race costs a second row and its day slot.
|
||||
*/
|
||||
#oneSendPerGroup<T>(
|
||||
recipientId: number,
|
||||
appUid: string,
|
||||
message: AppMessage,
|
||||
send: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!message.group) return send();
|
||||
// One send per group at a time, so a burst into an empty group writes
|
||||
// one row that the rest replace. A lock that is busy past the wait or
|
||||
// unreachable still sends: the race costs a second row and its day slot.
|
||||
return withRedisLock(
|
||||
this.clients.redis,
|
||||
`notif:app-message:group:${recipientId}:${appUid}:${message.group}`,
|
||||
@@ -430,6 +516,161 @@ export class NotificationDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Delayed sends -----------------------------------------------
|
||||
|
||||
#schedules(): ScheduleServiceContract {
|
||||
const schedules = this.services.schedules;
|
||||
if (!schedules) throw schedulesUnavailable();
|
||||
return schedules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an app's message to go out at `sendAt`. A recipient named through a
|
||||
* share is checked now without spending its budget; every limit is spent
|
||||
* when it goes, and the share is checked again then.
|
||||
*/
|
||||
async #scheduleAppMessage(
|
||||
actor: UserActor,
|
||||
message: AppMessage,
|
||||
handle: string | undefined,
|
||||
plan: DeferredSendPlan,
|
||||
): Promise<{ scheduled: { scheduleId: string; sendAt: number } }> {
|
||||
if (handle !== undefined)
|
||||
await this.services.kvShare.resolveNotifyRecipient(actor, handle, {
|
||||
spendBudget: false,
|
||||
});
|
||||
return {
|
||||
scheduled: await this.#schedules().createDeferredAppMessage(
|
||||
plan,
|
||||
handle === undefined ? { message } : { message, to: handle },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** For extensions. A field, not a method, so `/drivers/call` can't reach it. */
|
||||
readonly deferredAppMessages = {
|
||||
write: (input: DeferredWriteInput, tx: DeferredWriteTx) =>
|
||||
this.#writeDeferredAppMessage(input, tx),
|
||||
};
|
||||
|
||||
/**
|
||||
* Check the sender and recipient as they stand now, spend the recipient's
|
||||
* limits, and write the mailbox row through `tx`, fenced on its guard.
|
||||
* Refusals are the same `HttpError`s an immediate send throws.
|
||||
*/
|
||||
async #writeDeferredAppMessage(
|
||||
input: DeferredWriteInput,
|
||||
tx: DeferredWriteTx,
|
||||
): Promise<DeferredWriteOutcome> {
|
||||
const { holderUserId, sendKey, signal } = input;
|
||||
const sender = await this.services.events.resolveScheduleActor(
|
||||
holderUserId,
|
||||
input.appUid,
|
||||
);
|
||||
if (!sender) {
|
||||
// A lagging replica can miss an app the primary has: retried.
|
||||
const there =
|
||||
(await this.stores.app.uidExistsOnPrimary(input.appUid)) &&
|
||||
(await this.stores.user.getById(holderUserId)) !== null;
|
||||
if (there) throw new Error('the sender could not be resolved');
|
||||
return 'dropped';
|
||||
}
|
||||
if (sender.user?.suspended) return 'senderSuspended';
|
||||
// Uninstalled since, and the cancel at uninstall missed it.
|
||||
const appId = sender.effectiveApp?.id;
|
||||
if (
|
||||
typeof appId !== 'number' ||
|
||||
!(await this.stores.permission.hasAnyUserAppPerm(
|
||||
holderUserId,
|
||||
appId,
|
||||
))
|
||||
)
|
||||
return 'dropped';
|
||||
const stored = parseDelayedAppMessage(input.payload);
|
||||
if (!stored) return 'dropped';
|
||||
const { message } = stored;
|
||||
|
||||
let recipientId = holderUserId;
|
||||
let appUid = input.appUid;
|
||||
if (stored.to !== undefined) {
|
||||
const recipient =
|
||||
await this.services.kvShare.resolveNotifyRecipient(
|
||||
sender,
|
||||
stored.to,
|
||||
);
|
||||
recipientId = recipient.recipientUserId;
|
||||
appUid = recipient.appUid;
|
||||
}
|
||||
|
||||
return this.#oneSendPerGroup(recipientId, appUid, message, async () => {
|
||||
// Primary, as for an immediate send.
|
||||
const open = message.group
|
||||
? await this.stores.notification.getUnseenByGroup(
|
||||
recipientId,
|
||||
appUid,
|
||||
message.group,
|
||||
{ primary: true },
|
||||
)
|
||||
: null;
|
||||
await this.#spendAppMessageLimits(
|
||||
recipientId,
|
||||
appUid,
|
||||
open ? ['minute'] : ['minute', 'day'],
|
||||
);
|
||||
message.app = appMessageSender(
|
||||
appUid,
|
||||
await this.stores.app.getByUid(appUid),
|
||||
);
|
||||
// Another attempt may hold the lease now; hand the claim back.
|
||||
if (signal.aborted) throw new Error('delayed send lost its lease');
|
||||
|
||||
const registered = resolveNotificationWrite(
|
||||
APP_MESSAGE_TYPE,
|
||||
appUid,
|
||||
);
|
||||
const delivered = await tx.commit(
|
||||
this.stores.notification.sendEntries(
|
||||
{
|
||||
sendKey,
|
||||
userId: recipientId,
|
||||
value: { ...message, type: registered.type },
|
||||
type: registered.type,
|
||||
audience: registered.audience,
|
||||
appUid,
|
||||
group: message.group ?? null,
|
||||
replaceUid: open ? String(open.uid) : null,
|
||||
at: Date.now(),
|
||||
},
|
||||
tx.guard,
|
||||
),
|
||||
);
|
||||
if (!delivered) return 'lost';
|
||||
if (open)
|
||||
await this.#spendDayIfFresh(recipientId, appUid, open, sendKey);
|
||||
return 'delivered';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A replacement that found its row shown since the lookup wrote a new one,
|
||||
* which costs a day slot. Already written, so a full day is not a refusal.
|
||||
*/
|
||||
async #spendDayIfFresh(
|
||||
recipientId: number,
|
||||
appUid: string,
|
||||
open: Record<string, unknown>,
|
||||
sendKey: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const written =
|
||||
await this.stores.notification.getBySendKey(sendKey);
|
||||
if (written && written.uid !== open.uid)
|
||||
await this.#spendAppMessageLimits(recipientId, appUid, ['day']);
|
||||
} catch {
|
||||
// The row is written; a slot not spent is the only loss.
|
||||
}
|
||||
}
|
||||
|
||||
async #replaceOrWriteAppMessage(
|
||||
recipientId: number,
|
||||
appUid: string,
|
||||
|
||||
@@ -324,8 +324,10 @@ describe('AI cost factor', () => {
|
||||
it('gates and holds at the factored cost', async () => {
|
||||
listen(1.3);
|
||||
const check = vi
|
||||
.spyOn(metering, 'hasEnoughCredits')
|
||||
.mockResolvedValue(true);
|
||||
.spyOn(metering, 'reserveCreditsIfAffordable')
|
||||
.mockImplementation((who, amount, opts) =>
|
||||
metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
const reserve = vi.spyOn(metering, 'reserveCredits');
|
||||
try {
|
||||
const hold = await scoped.reserveAiCredits(
|
||||
@@ -334,7 +336,11 @@ describe('AI cost factor', () => {
|
||||
1000,
|
||||
);
|
||||
await hold?.release();
|
||||
expect(check).toHaveBeenCalledWith(actor, 1300);
|
||||
expect(check).toHaveBeenCalledExactlyOnceWith(
|
||||
actor,
|
||||
1300,
|
||||
undefined,
|
||||
);
|
||||
expect(reserve).toHaveBeenCalledWith(actor, 1300, undefined);
|
||||
} finally {
|
||||
check.mockRestore();
|
||||
@@ -342,10 +348,40 @@ describe('AI cost factor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('admits one of two concurrent holds the balance only covers once', async () => {
|
||||
listen(1.3);
|
||||
vi.spyOn(metering, 'getAllowedUsage').mockResolvedValue({
|
||||
remaining: 20_000_000,
|
||||
monthUsageAllowance: 20_000_000,
|
||||
addons: {},
|
||||
});
|
||||
const holds = await Promise.all(
|
||||
[0, 1].map(() =>
|
||||
scoped.reserveAiCredits(
|
||||
actor,
|
||||
'openai:gpt-realtime:realtime:audio_output',
|
||||
12_500_000,
|
||||
),
|
||||
),
|
||||
);
|
||||
try {
|
||||
expect(holds.filter((h) => h !== null)).toHaveLength(1);
|
||||
// The factor applies once: 12.5M at 1.3 is 16.25M held.
|
||||
expect(await metering.getOutstandingHolds(actor)).toBe(
|
||||
16_250_000,
|
||||
);
|
||||
} finally {
|
||||
await Promise.all(holds.map((h) => h?.release()));
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it('passes a hold lifetime through to the service', async () => {
|
||||
const check = vi
|
||||
.spyOn(metering, 'hasEnoughCredits')
|
||||
.mockResolvedValue(true);
|
||||
.spyOn(metering, 'reserveCreditsIfAffordable')
|
||||
.mockImplementation((who, amount, opts) =>
|
||||
metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
const reserve = vi.spyOn(metering, 'reserveCredits');
|
||||
try {
|
||||
const hold = await scoped.reserveAiCredits(
|
||||
@@ -367,8 +403,12 @@ describe('AI cost factor', () => {
|
||||
it('takes no hold when the factored cost is unaffordable', async () => {
|
||||
listen(1.3);
|
||||
const check = vi
|
||||
.spyOn(metering, 'hasEnoughCredits')
|
||||
.mockImplementation(async (_actor, amount) => amount <= 1000);
|
||||
.spyOn(metering, 'reserveCreditsIfAffordable')
|
||||
.mockImplementation(async (who, amount, opts) =>
|
||||
amount <= 1000
|
||||
? metering.reserveCredits(who, amount, opts)
|
||||
: null,
|
||||
);
|
||||
const reserve = vi.spyOn(metering, 'reserveCredits');
|
||||
try {
|
||||
const hold = await scoped.reserveAiCredits(
|
||||
@@ -409,8 +449,10 @@ describe('AI cost factor', () => {
|
||||
it('holds the factored cost while run executes, then releases it', async () => {
|
||||
listen(1.3);
|
||||
const check = vi
|
||||
.spyOn(metering, 'hasEnoughCredits')
|
||||
.mockResolvedValue(true);
|
||||
.spyOn(metering, 'reserveCreditsIfAffordable')
|
||||
.mockImplementation((who, amount, opts) =>
|
||||
metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
try {
|
||||
let heldDuringRun = 0;
|
||||
const result = await withAiCreditHold(
|
||||
@@ -448,8 +490,10 @@ describe('AI cost factor', () => {
|
||||
};
|
||||
server.clients.event.on('ai.cost.factor.*', slowFactor as never);
|
||||
const check = vi
|
||||
.spyOn(metering, 'hasEnoughCredits')
|
||||
.mockResolvedValue(true);
|
||||
.spyOn(metering, 'reserveCreditsIfAffordable')
|
||||
.mockImplementation((who, amount, opts) =>
|
||||
metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
try {
|
||||
await runWithContext({ actor }, () =>
|
||||
withAiCreditHold(
|
||||
@@ -485,8 +529,10 @@ describe('AI cost factor', () => {
|
||||
|
||||
it('releases the hold when the usage write fails', async () => {
|
||||
const check = vi
|
||||
.spyOn(metering, 'hasEnoughCredits')
|
||||
.mockResolvedValue(true);
|
||||
.spyOn(metering, 'reserveCreditsIfAffordable')
|
||||
.mockImplementation((who, amount, opts) =>
|
||||
metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
const write = vi
|
||||
.spyOn(metering, 'incrementUsage')
|
||||
.mockRejectedValueOnce(new Error('ledger down'));
|
||||
@@ -515,8 +561,10 @@ describe('AI cost factor', () => {
|
||||
|
||||
it('releases at once when nothing was recorded', async () => {
|
||||
const check = vi
|
||||
.spyOn(metering, 'hasEnoughCredits')
|
||||
.mockResolvedValue(true);
|
||||
.spyOn(metering, 'reserveCreditsIfAffordable')
|
||||
.mockImplementation((who, amount, opts) =>
|
||||
metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
try {
|
||||
await runWithContext({ actor }, () =>
|
||||
withAiCreditHold(
|
||||
@@ -535,8 +583,10 @@ describe('AI cost factor', () => {
|
||||
|
||||
it('releases the hold when run throws', async () => {
|
||||
const check = vi
|
||||
.spyOn(metering, 'hasEnoughCredits')
|
||||
.mockResolvedValue(true);
|
||||
.spyOn(metering, 'reserveCreditsIfAffordable')
|
||||
.mockImplementation((who, amount, opts) =>
|
||||
metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
const boom = new Error('upstream down');
|
||||
try {
|
||||
await expect(
|
||||
@@ -552,8 +602,8 @@ describe('AI cost factor', () => {
|
||||
|
||||
it('refuses with a 402 and never runs when the cost is unaffordable', async () => {
|
||||
const check = vi
|
||||
.spyOn(metering, 'hasEnoughCredits')
|
||||
.mockResolvedValue(false);
|
||||
.spyOn(metering, 'reserveCreditsIfAffordable')
|
||||
.mockResolvedValue(null);
|
||||
const run = vi.fn();
|
||||
try {
|
||||
await expect(
|
||||
@@ -575,8 +625,10 @@ describe('AI cost factor', () => {
|
||||
const relay = async (source: Readable) => {
|
||||
const record = vi.fn();
|
||||
const check = vi
|
||||
.spyOn(metering, 'hasEnoughCredits')
|
||||
.mockResolvedValue(true);
|
||||
.spyOn(metering, 'reserveCreditsIfAffordable')
|
||||
.mockImplementation((who, amount, opts) =>
|
||||
metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
try {
|
||||
const result = await withStreamedAiCharge(
|
||||
scoped,
|
||||
@@ -624,8 +676,10 @@ describe('AI cost factor', () => {
|
||||
landCharge = resolve;
|
||||
});
|
||||
const check = vi
|
||||
.spyOn(metering, 'hasEnoughCredits')
|
||||
.mockResolvedValue(true);
|
||||
.spyOn(metering, 'reserveCreditsIfAffordable')
|
||||
.mockImplementation((who, amount, opts) =>
|
||||
metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
try {
|
||||
const result = await withStreamedAiCharge(
|
||||
scoped,
|
||||
@@ -655,8 +709,10 @@ describe('AI cost factor', () => {
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
|
||||
const refresh = vi.spyOn(server.stores.creditHold, 'refresh');
|
||||
const check = vi
|
||||
.spyOn(metering, 'hasEnoughCredits')
|
||||
.mockResolvedValue(true);
|
||||
.spyOn(metering, 'reserveCreditsIfAffordable')
|
||||
.mockImplementation((who, amount, opts) =>
|
||||
metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
let landCharge = () => {};
|
||||
const charge = new Promise<void>((resolve) => {
|
||||
landCharge = resolve;
|
||||
@@ -697,8 +753,10 @@ describe('AI cost factor', () => {
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
|
||||
const refresh = vi.spyOn(server.stores.creditHold, 'refresh');
|
||||
const check = vi
|
||||
.spyOn(metering, 'hasEnoughCredits')
|
||||
.mockResolvedValue(true);
|
||||
.spyOn(metering, 'reserveCreditsIfAffordable')
|
||||
.mockImplementation((who, amount, opts) =>
|
||||
metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
let opened = () => {};
|
||||
const opening = new Promise<void>((resolve) => {
|
||||
opened = resolve;
|
||||
@@ -741,8 +799,10 @@ describe('AI cost factor', () => {
|
||||
|
||||
it('releases the hold when recording the charge throws', async () => {
|
||||
const check = vi
|
||||
.spyOn(metering, 'hasEnoughCredits')
|
||||
.mockResolvedValue(true);
|
||||
.spyOn(metering, 'reserveCreditsIfAffordable')
|
||||
.mockImplementation((who, amount, opts) =>
|
||||
metering.reserveCredits(who, amount, opts),
|
||||
);
|
||||
try {
|
||||
const result = await withStreamedAiCharge(
|
||||
scoped,
|
||||
|
||||
@@ -354,10 +354,12 @@ export function withAiCostFactor(
|
||||
? await costFactor(actor, aiModelKey(usageType))
|
||||
: 1;
|
||||
const cost = factor === 1 ? amount : scaleCost(amount, factor);
|
||||
if (!(await metering.hasEnoughCredits(actor, cost))) return null;
|
||||
return outlastingWrites(
|
||||
await metering.reserveCredits(actor, cost, opts),
|
||||
const hold = await metering.reserveCreditsIfAffordable(
|
||||
actor,
|
||||
cost,
|
||||
opts,
|
||||
);
|
||||
return hold && outlastingWrites(hold);
|
||||
};
|
||||
|
||||
const reserveCredits = async (
|
||||
|
||||
@@ -129,6 +129,27 @@ describe('pipeStreamResult', () => {
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds at most two close listeners to the response', async () => {
|
||||
// The response already carries close listeners from metering,
|
||||
// compression and tracing; Node warns past ten.
|
||||
let added = -1;
|
||||
server = createServer((_req, res) => {
|
||||
const before = res.listenerCount('close');
|
||||
pipeStreamResult(Readable.from(['a', 'b']), res, 'test.run');
|
||||
added = res.listenerCount('close') - before;
|
||||
});
|
||||
await new Promise<void>((resolve) => server!.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
await new Promise<void>((resolve) =>
|
||||
get(`http://127.0.0.1:${port}/`, (res) => {
|
||||
res.resume();
|
||||
res.on('end', () => resolve());
|
||||
}),
|
||||
);
|
||||
expect(added).toBeGreaterThan(0);
|
||||
expect(added).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('drops the source, without throwing, when the client left before the result was ready', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const source = Readable.from(['late']);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { pipeline, type Readable } from 'node:stream';
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
// Only what's used here, so callers on another @types/node (or express's
|
||||
// Response) still fit.
|
||||
@@ -32,24 +32,32 @@ type ResponseSink = NodeJS.WritableStream & {
|
||||
* mid-body destroys the response, so the client sees an aborted transfer rather
|
||||
* than a clean end, and the failure is logged once instead of surfacing as an
|
||||
* unhandled stream error. A client that hangs up stops the source.
|
||||
*
|
||||
* `pipe` rather than `pipeline`: pipeline puts seven close listeners on the
|
||||
* response, and with the ones metering, compression and tracing add, every
|
||||
* streamed response tripped Node's ten-listener warning.
|
||||
*/
|
||||
export function pipeStreamResult(
|
||||
stream: Readable,
|
||||
res: ResponseSink,
|
||||
label: string,
|
||||
): void {
|
||||
// The caller hung up while the driver ran; `pipeline` throws on a closed
|
||||
// destination, so drop the source instead.
|
||||
// The caller hung up while the driver ran: nothing will read the source.
|
||||
if (res.closed || res.destroyed) {
|
||||
stream.destroy();
|
||||
return;
|
||||
}
|
||||
pipeline(stream, res, (err) => {
|
||||
if (!err) return;
|
||||
stream.once('error', (err) => {
|
||||
stream.unpipe(res);
|
||||
if (!res.destroyed) res.destroy();
|
||||
// The client hanging up is not a failure of ours.
|
||||
if ((err as { code?: string }).code === 'ERR_STREAM_PREMATURE_CLOSE')
|
||||
return;
|
||||
console.warn(`[drivers] ${label} stream failed mid-response:`, err);
|
||||
});
|
||||
// A response that closes before the source ended is a client hanging up.
|
||||
res.once('close', () => {
|
||||
if (stream.readableEnded) return;
|
||||
stream.unpipe(res);
|
||||
stream.destroy();
|
||||
});
|
||||
res.once('error', () => stream.destroy());
|
||||
stream.pipe(res);
|
||||
}
|
||||
@@ -754,6 +754,50 @@ describe('WorkerDriver.create with a configured deploy backend', () => {
|
||||
expect(putCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
const uploadedMetadata = () => {
|
||||
const [, init] = putCalls().at(-1)!;
|
||||
const form = (init as RequestInit).body as FormData;
|
||||
return JSON.parse(form.get('metadata') as string);
|
||||
};
|
||||
|
||||
it('sends limits.cpu_ms in the upload metadata when cpuMs is set', async () => {
|
||||
const { user, actor } = await makeUser();
|
||||
const path = `/${user.username}/cpu.worker.js`;
|
||||
await writeSource(actor, user.id, path, 'export default {}');
|
||||
const scriptName = `dt-cpu-${user.username}`;
|
||||
await inCtx(actor, () =>
|
||||
target.create({
|
||||
appId: '',
|
||||
workerName: scriptName,
|
||||
filePath: path,
|
||||
[INTERNAL_DEPLOY_TARGET]: { scriptName, cpuMs: 50 },
|
||||
}),
|
||||
);
|
||||
expect(uploadedMetadata().limits).toEqual({ cpu_ms: 50 });
|
||||
});
|
||||
|
||||
it('sends no limits key without cpuMs, and none on an ordinary create', async () => {
|
||||
const { user, actor } = await makeUser();
|
||||
const path = `/${user.username}/nocpu.worker.js`;
|
||||
await writeSource(actor, user.id, path, 'export default {}');
|
||||
const scriptName = `dt-nocpu-${user.username}`;
|
||||
await deployTo(actor, path, scriptName);
|
||||
expect(uploadedMetadata()).not.toHaveProperty('limits');
|
||||
|
||||
const name = `plain-${user.username}`;
|
||||
await inCtx(actor, () =>
|
||||
target.create({ appId: '', workerName: name, filePath: path }),
|
||||
);
|
||||
const metadata = uploadedMetadata();
|
||||
expect(metadata).not.toHaveProperty('limits');
|
||||
expect(Object.keys(metadata)).toEqual([
|
||||
'body_part',
|
||||
'compatibility_flags',
|
||||
'compatibility_date',
|
||||
'bindings',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses a file another user owns, even with manage on it', async () => {
|
||||
const owner = await makeUser();
|
||||
const deployer = await makeUser();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// however the interactions with the Puter API are
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { Actor, makeActor as resolveActor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { DEFAULT_FREE_SUBSCRIPTION } from '../../services/metering/consts.js';
|
||||
import {
|
||||
setupPuterTestEnv,
|
||||
setupTestServer,
|
||||
@@ -797,6 +799,174 @@ describe('WorkerDriver', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('rented-unit cap', () => {
|
||||
const FREE_CAP = 16;
|
||||
const PAID_CAP = 100;
|
||||
|
||||
// The cap sits after the deploy-credentials gate, so the driver needs
|
||||
// credentials to reach it; a create under the cap then fails on the
|
||||
// missing source file, which is what tells it past the cap check.
|
||||
let originalWorkersConfig: unknown;
|
||||
let originalEvents: unknown;
|
||||
beforeAll(() => {
|
||||
const driverConfig = (
|
||||
target as unknown as { config: Record<string, unknown> }
|
||||
).config;
|
||||
originalEvents = driverConfig.events;
|
||||
driverConfig.events = { userHandlers: true };
|
||||
originalWorkersConfig = driverConfig.workers;
|
||||
driverConfig.workers = {
|
||||
XAUTHKEY: 'test-key',
|
||||
ACCOUNTID: 'test-account',
|
||||
};
|
||||
});
|
||||
afterAll(() => {
|
||||
const driverConfig = (
|
||||
target as unknown as { config: Record<string, unknown> }
|
||||
).config;
|
||||
driverConfig.workers = originalWorkersConfig;
|
||||
driverConfig.events = originalEvents;
|
||||
});
|
||||
|
||||
const holding = (
|
||||
plan: string,
|
||||
held: { workers?: number; scopes?: number; owned?: number },
|
||||
) => {
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getActorSubscription',
|
||||
).mockResolvedValue({ id: plan } as never);
|
||||
vi.spyOn(
|
||||
server.stores.subdomain,
|
||||
'countByUserIdAndPrefix',
|
||||
).mockResolvedValue(held.workers ?? 0);
|
||||
vi.spyOn(
|
||||
server.stores.eventHandler,
|
||||
'countUserScopesWithHandlersForUser',
|
||||
).mockResolvedValue(held.scopes ?? 0);
|
||||
vi.spyOn(
|
||||
server.stores.eventHandler,
|
||||
'countAppsWithHandlersForOwner',
|
||||
).mockResolvedValue(held.owned ?? 0);
|
||||
};
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createWorker = () =>
|
||||
inCtx(() =>
|
||||
target.create({
|
||||
appId: 'test-app',
|
||||
workerName: 'capped',
|
||||
filePath: '/test.js',
|
||||
}),
|
||||
);
|
||||
|
||||
it('keeps the flat 100-worker cap over ordinary workers with user scopes off', async () => {
|
||||
const driverConfig = (
|
||||
target as unknown as { config: Record<string, unknown> }
|
||||
).config;
|
||||
driverConfig.events = { userHandlers: false };
|
||||
try {
|
||||
// 16 workers is fine here, and scopes and owned apps don't count.
|
||||
holding(DEFAULT_FREE_SUBSCRIPTION, {
|
||||
workers: 16,
|
||||
scopes: 50,
|
||||
owned: 50,
|
||||
});
|
||||
const list = vi
|
||||
.spyOn(server.stores.subdomain, 'listByUserIdAndPrefix')
|
||||
.mockResolvedValue(
|
||||
Array.from({ length: 16 }, () => ({})) as never,
|
||||
);
|
||||
await expect(createWorker()).rejects.not.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
|
||||
list.mockResolvedValue(
|
||||
Array.from({ length: 100 }, () => ({})) as never,
|
||||
);
|
||||
await expect(createWorker()).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
message: 'Worker limit reached (max 100)',
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
} finally {
|
||||
driverConfig.events = { userHandlers: true };
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a free user one unit past the cap, across all three sources', async () => {
|
||||
for (const held of [
|
||||
{ workers: FREE_CAP },
|
||||
{ scopes: FREE_CAP },
|
||||
{ owned: FREE_CAP },
|
||||
{ workers: 10, scopes: 4, owned: 2 },
|
||||
]) {
|
||||
holding(DEFAULT_FREE_SUBSCRIPTION, held);
|
||||
await expect(createWorker()).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('lets a free user create up to the cap', async () => {
|
||||
holding(DEFAULT_FREE_SUBSCRIPTION, {
|
||||
workers: 10,
|
||||
scopes: 3,
|
||||
owned: 2,
|
||||
});
|
||||
await expect(createWorker()).rejects.not.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a paid user the unit after 100', async () => {
|
||||
holding('paid-plan', { workers: 90, scopes: 5, owned: 5 });
|
||||
await expect(createWorker()).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
holding('paid-plan', { workers: 90, scopes: 5, owned: 4 });
|
||||
await expect(createWorker()).rejects.not.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
expect(PAID_CAP).toBe(100);
|
||||
});
|
||||
|
||||
it('keeps a free user over the cap, redeploys what they hold, and refuses a new one', async () => {
|
||||
holding(DEFAULT_FREE_SUBSCRIPTION, { workers: 20 });
|
||||
const user = actor.user;
|
||||
const exists = vi
|
||||
.spyOn(server.stores.subdomain, 'getBySubdomain')
|
||||
.mockImplementation(
|
||||
async (name: string) =>
|
||||
(name === 'workers.puter.capped'
|
||||
? { user_id: user.id, uuid: 'existing-uuid' }
|
||||
: null) as never,
|
||||
);
|
||||
const noApp = makeActor({ app: undefined });
|
||||
const create = (workerName: string) =>
|
||||
inCtx(
|
||||
() =>
|
||||
target.create({
|
||||
appId: '',
|
||||
workerName,
|
||||
filePath: '/test.js',
|
||||
}),
|
||||
noApp,
|
||||
);
|
||||
|
||||
await expect(create('capped')).rejects.not.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
await expect(create('another')).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
exists.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('admission bypass', () => {
|
||||
const unverified = (): Actor =>
|
||||
makeActor({
|
||||
|
||||
@@ -43,6 +43,7 @@ import { PuterDriver } from '../types.js';
|
||||
import { BoundedTtlMap } from '../../util/boundedTtlMap.js';
|
||||
import { awaitLater } from '../../util/concurrency.js';
|
||||
import { isUniqueViolation } from '../../util/dbError.js';
|
||||
import { assertRentedUnitRoom } from '../../util/rentedUnits.js';
|
||||
import { loadFileInput } from '../util/fileInput.js';
|
||||
import {
|
||||
normalizeLimit,
|
||||
@@ -57,6 +58,7 @@ const CF_BASE_URL = 'https://api.cloudflare.com/client/v4/accounts';
|
||||
export const WORKER_COMPATIBILITY_DATE = '2025-07-15';
|
||||
export const WORKER_COMPATIBILITY_FLAGS = ['global_fetch_strictly_public'];
|
||||
const WORKER_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
/** Cap on ordinary workers while user-scope handlers are off. */
|
||||
const MAX_WORKERS_PER_USER = 100;
|
||||
|
||||
/**
|
||||
@@ -179,12 +181,17 @@ export interface InternalDeployTarget {
|
||||
* scripts belong to what.
|
||||
*/
|
||||
tags?: string[];
|
||||
/**
|
||||
* Per-invocation CPU limit in milliseconds, sent with the upload. Unset
|
||||
* sends no limit. Ignored by the local runtime.
|
||||
*/
|
||||
cpuMs?: number;
|
||||
}
|
||||
|
||||
const MAX_SOURCE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
// How far to scan an app's child apps when resolving which workers it may
|
||||
// see. A user is capped at MAX_WORKERS_PER_USER workers, so only that many
|
||||
// child apps can actually own one; the ceiling is generous slack over that.
|
||||
// see. A user is capped at their plan's rented units, so only that many
|
||||
// child apps can actually own a worker; the ceiling is generous slack over that.
|
||||
const CHILD_APP_SCAN_LIMIT = 1000;
|
||||
|
||||
// -- Preamble --------------------------------------------------------
|
||||
@@ -452,26 +459,34 @@ export class WorkerDriver extends PuterDriver {
|
||||
|
||||
this.#requireCfConfig();
|
||||
|
||||
// Quota check — count existing workers.puter.* subdomains owned by user
|
||||
//
|
||||
// A deploy target creates no such row, so this count can neither see
|
||||
// its scripts nor be grown by them; running it would only make an
|
||||
// unrelated worker quota decide whether these deploy. Whatever bounds
|
||||
// them has to be counted where they actually live, and belongs to the
|
||||
// caller that owns their lifecycle.
|
||||
const existingWorkers =
|
||||
skipAdmission || deployTarget
|
||||
? []
|
||||
: await this.stores.subdomain.listByUserIdAndPrefix(
|
||||
actor.user.id,
|
||||
WORKER_SUBDOMAIN_PREFIX,
|
||||
);
|
||||
if (existingWorkers.length >= MAX_WORKERS_PER_USER) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
`Worker limit reached (max ${MAX_WORKERS_PER_USER})`,
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
// With user scopes on, a new worker is one more rented unit under the
|
||||
// plan's cap, and redeploying an existing worker is never refused.
|
||||
// Deploy targets create no worker row and are bounded by their caller.
|
||||
if (this.config.events?.userHandlers === true) {
|
||||
if (!skipAdmission && !deployTarget && !existingSub) {
|
||||
await assertRentedUnitRoom(
|
||||
{
|
||||
stores: this.stores,
|
||||
metering: this.services.metering,
|
||||
},
|
||||
actor,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const existingWorkers =
|
||||
skipAdmission || deployTarget
|
||||
? []
|
||||
: await this.stores.subdomain.listByUserIdAndPrefix(
|
||||
actor.user.id,
|
||||
WORKER_SUBDOMAIN_PREFIX,
|
||||
);
|
||||
if (existingWorkers.length >= MAX_WORKERS_PER_USER) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
`Worker limit reached (max ${MAX_WORKERS_PER_USER})`,
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Read source file. loadFileInput runs the read-ACL check internally
|
||||
@@ -940,6 +955,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
compatibility_flags: WORKER_COMPATIBILITY_FLAGS,
|
||||
compatibility_date: WORKER_COMPATIBILITY_DATE,
|
||||
...(target?.tags ? { tags: target.tags } : {}),
|
||||
...(target?.cpuMs ? { limits: { cpu_ms: target.cpuMs } } : {}),
|
||||
bindings: [
|
||||
// A script deployed without a token gets no binding at all,
|
||||
// rather than an empty one for its code to find.
|
||||
|
||||
@@ -36,6 +36,10 @@ import { Controller, Get } from './core/http/decorators.ts';
|
||||
import { HttpError } from './core/http/HttpError.ts';
|
||||
import { extension, extensionStore } from './extensions.ts';
|
||||
import { PuterServer } from './server.ts';
|
||||
import {
|
||||
JobScheduler,
|
||||
type JobDefinition,
|
||||
} from './services/jobs/JobScheduler.ts';
|
||||
import {
|
||||
allocateEphemeralPort,
|
||||
createTestUser,
|
||||
@@ -332,6 +336,70 @@ describe('PuterServer host header validation', () => {
|
||||
expect(api.headers['x-frame-options']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows deployment subdomains to frame the app browser embed', async () => {
|
||||
const parent = `http://spreadsheet.puter.localhost:${port}`;
|
||||
for (const path of ['/embed/apps', '/embed/apps/']) {
|
||||
const res = await request(path, {
|
||||
host: `puter.localhost:${port}`,
|
||||
referer: `${parent}/sheet/1`,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['x-frame-options']).toBeUndefined();
|
||||
expect(res.headers['content-security-policy']).toBe(
|
||||
`frame-ancestors 'self' ${parent}`,
|
||||
);
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
expect(res.body).toContain('/dist/apps-embed.min.js');
|
||||
expect(res.body).not.toContain('/dist/bundle.min.js');
|
||||
expect(res.body).toContain(`http://api.puter.localhost:${port}`);
|
||||
const config = JSON.parse(
|
||||
res.body.match(/id="app-browser-config">(.*?)<\/script>/)[1],
|
||||
);
|
||||
expect(Array.isArray(config.guestApps)).toBe(true);
|
||||
}
|
||||
const gui = await request('/embed/apps', {
|
||||
host: `puter.localhost:${port}`,
|
||||
referer: `http://puter.localhost:${port}/dashboard`,
|
||||
});
|
||||
expect(gui.headers['content-security-policy']).toBe("frame-ancestors 'self'");
|
||||
const dashboard = await request('/dashboard', { host: `puter.localhost:${port}` });
|
||||
expect(dashboard.headers['x-frame-options']).toBe('SAMEORIGIN');
|
||||
const api = await request('/embed/apps', { host: `api.puter.localhost:${port}` });
|
||||
expect(api.status).toBe(404);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a user-hosted site', 'http://someone.site.puter.localhost:PORT/'],
|
||||
['a private app host', 'http://someone.app.puter.localhost:PORT/'],
|
||||
['the API host', 'http://api.puter.localhost:PORT/'],
|
||||
['the WebDAV host', 'http://dav.puter.localhost:PORT/'],
|
||||
['a native app host', 'http://editor.puter.localhost:PORT/'],
|
||||
['a nested subdomain', 'http://a.spreadsheet.puter.localhost:PORT/'],
|
||||
['another site', 'http://evil.test:PORT/'],
|
||||
['another port', 'http://spreadsheet.puter.localhost:1/'],
|
||||
['a suppressed referrer', undefined],
|
||||
])('keeps the embed same-origin only when framed by %s', async (_label, referer) => {
|
||||
const res = await request('/embed/apps', {
|
||||
host: `puter.localhost:${port}`,
|
||||
...(referer ? { referer: referer.replace('PORT', String(port)) } : {}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-security-policy']).toBe("frame-ancestors 'self'");
|
||||
});
|
||||
|
||||
it.each([
|
||||
['plain', ''],
|
||||
// Past the parser's 1000-pair limit, so `req.query` comes back empty.
|
||||
['padded', '&'.repeat(1000)],
|
||||
])('strips %s SDK bootstrap query parameters from the embed route', async (_label, padding) => {
|
||||
const res = await request(`/embed/apps?${padding}puter.app_instance_id=other&puter.api_origin=https://other.test`, {
|
||||
host: `puter.localhost:${port}`,
|
||||
});
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe('/embed/apps');
|
||||
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
|
||||
});
|
||||
|
||||
it('blocks a request the ip.validate listeners veto', async () => {
|
||||
const handler = (_key: unknown, data: unknown) => {
|
||||
(data as { allow: boolean }).allow = false;
|
||||
@@ -1488,6 +1556,104 @@ describe('PuterServer keep-alive timeout', () => {
|
||||
});
|
||||
|
||||
describe('PuterServer lifecycle hooks', () => {
|
||||
it('fails startup before running later hooks when a start hook rejects', async () => {
|
||||
const failure = new Error('startup failed');
|
||||
const later = vi.fn();
|
||||
class FailingStart extends JobScheduler {
|
||||
override async onServerStart() {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
class LaterStart extends JobScheduler {
|
||||
override async onServerStart() {
|
||||
later();
|
||||
}
|
||||
}
|
||||
extension.registerService('failingStart', FailingStart as never);
|
||||
extension.registerService('laterStart', LaterStart as never);
|
||||
try {
|
||||
await expect(setupTestServer()).rejects.toBe(failure);
|
||||
expect(later).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
delete (extensionStore.services as Record<string, unknown>)
|
||||
.failingStart;
|
||||
delete (extensionStore.services as Record<string, unknown>)
|
||||
.laterStart;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'continues teardown after failing hooks with listener callback %s',
|
||||
async (prepareFirst) => {
|
||||
const port = await allocateEphemeralPort();
|
||||
const server = await setupTestServer(
|
||||
{ port } as unknown as IConfig,
|
||||
{ listen: true },
|
||||
);
|
||||
const logged = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const ran: string[] = [];
|
||||
class FailingHooks {
|
||||
onServerPrepareShutdown() {
|
||||
ran.push('prepare:failed');
|
||||
throw new Error('prepare failed');
|
||||
}
|
||||
async onServerShutdown() {
|
||||
ran.push('shutdown:failed');
|
||||
throw new Error('shutdown failed');
|
||||
}
|
||||
}
|
||||
(server.services as Record<string, unknown>).fails =
|
||||
new FailingHooks();
|
||||
(server.services as Record<string, unknown>).after = {
|
||||
onServerPrepareShutdown: () => ran.push('prepare:after'),
|
||||
onServerShutdown: () => ran.push('shutdown:after'),
|
||||
};
|
||||
(server.clients as Record<string, unknown>).last = {
|
||||
onServerShutdown: () => ran.push('shutdown:client'),
|
||||
};
|
||||
try {
|
||||
if (prepareFirst) {
|
||||
await server.prepareShutdown();
|
||||
await vi.waitFor(() =>
|
||||
expect(ran).toContain('prepare:after'),
|
||||
);
|
||||
}
|
||||
await server.shutdown();
|
||||
expect(ran).toEqual([
|
||||
'prepare:failed',
|
||||
'prepare:after',
|
||||
'shutdown:failed',
|
||||
'shutdown:after',
|
||||
'shutdown:client',
|
||||
]);
|
||||
expect(logged).toHaveBeenCalledWith(
|
||||
'[server] onServerPrepareShutdown failed in FailingHooks',
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(logged).toHaveBeenCalledWith(
|
||||
'[server] onServerShutdown failed in FailingHooks',
|
||||
expect.any(Error),
|
||||
);
|
||||
await expect(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, '127.0.0.1');
|
||||
socket.on('connect', () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.on('error', reject);
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'ECONNREFUSED' });
|
||||
} finally {
|
||||
delete (server.services as Record<string, unknown>).fails;
|
||||
await server.shutdown();
|
||||
logged.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('runs drain-start and prepare-shutdown bottom-up, once, before tearing down', async () => {
|
||||
const port = await allocateEphemeralPort();
|
||||
const server = await setupTestServer({ port } as unknown as IConfig, {
|
||||
@@ -1537,6 +1703,136 @@ describe('PuterServer lifecycle hooks', () => {
|
||||
'shutdown:client',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps accepting connections until every drain-start hook settles, without one holding up another', async () => {
|
||||
const port = await allocateEphemeralPort();
|
||||
const server = await setupTestServer({ port } as unknown as IConfig, {
|
||||
listen: true,
|
||||
});
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => (release = resolve));
|
||||
let otherRan = false;
|
||||
(server.services as unknown as Record<string, unknown>).holdsListener =
|
||||
{ onServerDrainStart: () => held };
|
||||
(server.drivers as unknown as Record<string, unknown>).other = {
|
||||
onServerDrainStart: () => {
|
||||
otherRan = true;
|
||||
},
|
||||
};
|
||||
|
||||
const connect = () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, '127.0.0.1');
|
||||
socket.on('connect', () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.on('error', reject);
|
||||
});
|
||||
|
||||
const prepared = server.prepareShutdown({ drainMs: 1_000 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(otherRan).toBe(true);
|
||||
await expect(connect()).resolves.toBeUndefined();
|
||||
|
||||
release();
|
||||
await prepared;
|
||||
await expect(connect()).rejects.toMatchObject({
|
||||
code: 'ECONNREFUSED',
|
||||
});
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
it('runs every drain-start hook when one of them throws', async () => {
|
||||
const port = await allocateEphemeralPort();
|
||||
const server = await setupTestServer({ port } as unknown as IConfig, {
|
||||
listen: true,
|
||||
});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const ran: string[] = [];
|
||||
(server.stores as unknown as Record<string, unknown>).fails = {
|
||||
onServerDrainStart: () => {
|
||||
throw new Error('drain hook failed');
|
||||
},
|
||||
};
|
||||
(server.services as unknown as Record<string, unknown>).after = {
|
||||
onServerDrainStart: () => {
|
||||
ran.push('after');
|
||||
},
|
||||
};
|
||||
|
||||
await server.prepareShutdown({ drainMs: 1_000 });
|
||||
await server.shutdown();
|
||||
|
||||
expect(ran).toEqual(['after']);
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('onServerDrainStart failed'),
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PuterServer shutdown during prepare', () => {
|
||||
it('waits for prepare hooks the drain already started before tearing down', async () => {
|
||||
const port = await allocateEphemeralPort();
|
||||
const server = await setupTestServer({ port } as unknown as IConfig, {
|
||||
listen: true,
|
||||
});
|
||||
const order: string[] = [];
|
||||
(server.services as unknown as Record<string, unknown>).slowPrepare = {
|
||||
onServerPrepareShutdown: async () => {
|
||||
order.push('prepare:start');
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
order.push('prepare:done');
|
||||
},
|
||||
onServerShutdown: () => {
|
||||
order.push('shutdown');
|
||||
},
|
||||
};
|
||||
|
||||
await server.prepareShutdown({ drainMs: 0 });
|
||||
// The listener's close callback starts the prepare hooks.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await server.shutdown();
|
||||
|
||||
expect(order).toEqual(['prepare:start', 'prepare:done', 'shutdown']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* An extension may take over `services.jobs` (a fleet-wide scheduler). Core
|
||||
* services register their jobs on it at start, so it must keep the built-in's
|
||||
* first place in the layer.
|
||||
*/
|
||||
describe('PuterServer jobs replacement', () => {
|
||||
it('lets an extension service registered as `jobs` replace the built-in and keep its first position', async () => {
|
||||
const registered: string[] = [];
|
||||
class ReplacementJobs extends JobScheduler {
|
||||
override register(name: string, definition: JobDefinition) {
|
||||
registered.push(name);
|
||||
super.register(name, definition);
|
||||
}
|
||||
}
|
||||
const started = vi.spyOn(JobScheduler.prototype, 'onServerStart');
|
||||
extension.registerService('jobs', ReplacementJobs as never);
|
||||
let server: PuterServer | undefined;
|
||||
try {
|
||||
server = await setupTestServer();
|
||||
const { jobs } = server.services;
|
||||
expect(jobs).toBeInstanceOf(ReplacementJobs);
|
||||
expect(Object.keys(server.services)[0]).toBe('jobs');
|
||||
expect(extension.import('service').jobs).toBeInstanceOf(
|
||||
ReplacementJobs,
|
||||
);
|
||||
// Only the replacement gets lifecycle hooks.
|
||||
expect(started.mock.contexts).toEqual([jobs]);
|
||||
expect(registered).toContain('auth.sessions.purge');
|
||||
} finally {
|
||||
delete (extensionStore.services as Record<string, unknown>).jobs;
|
||||
started.mockRestore();
|
||||
await server?.shutdown();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
+56
-19
@@ -1743,12 +1743,7 @@ export class PuterServer {
|
||||
*/
|
||||
async #runHook(
|
||||
order: 'bottomUp' | 'topDown',
|
||||
hook:
|
||||
| 'onServerStart'
|
||||
| 'onServerDrainStart'
|
||||
| 'onServerPrepareShutdown'
|
||||
| 'onServerShutdown',
|
||||
info?: ServerDrainInfo,
|
||||
hook: 'onServerStart' | 'onServerPrepareShutdown' | 'onServerShutdown',
|
||||
) {
|
||||
const layers = [
|
||||
this.clients,
|
||||
@@ -1760,35 +1755,77 @@ export class PuterServer {
|
||||
if (order === 'topDown') layers.reverse();
|
||||
for (const layer of layers) {
|
||||
for (const instance of Object.values(layer) as WithLifecycle[]) {
|
||||
await instance[hook]?.(info);
|
||||
try {
|
||||
await instance[hook]?.();
|
||||
} catch (err) {
|
||||
if (hook === 'onServerStart') throw err;
|
||||
console.error(
|
||||
`[server] ${hook} failed in ${instance.constructor.name}`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#prepareShutdownHooksRan = false;
|
||||
/**
|
||||
* Run every `onServerDrainStart` concurrently, so one hook holding the
|
||||
* listener open doesn't delay the others, and one failing doesn't skip the
|
||||
* rest.
|
||||
*/
|
||||
async #runDrainStartHooks(info: ServerDrainInfo) {
|
||||
const instances = [
|
||||
this.clients,
|
||||
this.stores,
|
||||
this.services,
|
||||
this.controllers,
|
||||
this.drivers,
|
||||
].flatMap((layer) => Object.values(layer) as WithLifecycle[]);
|
||||
const results = await Promise.allSettled(
|
||||
instances.map(async (instance) =>
|
||||
instance.onServerDrainStart?.(info),
|
||||
),
|
||||
);
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'rejected') {
|
||||
console.error(
|
||||
`[server] onServerDrainStart failed in ${instances[i].constructor.name}`,
|
||||
result.reason,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#prepareShutdownHooks: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Run every layer's `onServerPrepareShutdown` exactly once, whichever of
|
||||
* `prepareShutdown()` / `shutdown()` gets there first.
|
||||
* `prepareShutdown()` / `shutdown()` gets there first; the other waits for
|
||||
* that run to finish.
|
||||
*/
|
||||
async #runPrepareShutdownHooks() {
|
||||
if (this.#prepareShutdownHooksRan) return;
|
||||
this.#prepareShutdownHooksRan = true;
|
||||
await this.#runHook('bottomUp', 'onServerPrepareShutdown');
|
||||
#runPrepareShutdownHooks(): Promise<void> {
|
||||
this.#prepareShutdownHooks ??= this.#runHook(
|
||||
'bottomUp',
|
||||
'onServerPrepareShutdown',
|
||||
);
|
||||
return this.#prepareShutdownHooks;
|
||||
}
|
||||
|
||||
async prepareShutdown(info: ServerDrainInfo = {}) {
|
||||
if (this.#server) {
|
||||
try {
|
||||
await this.#runHook('bottomUp', 'onServerDrainStart', info);
|
||||
} catch (err) {
|
||||
console.error('[server] onServerDrainStart failed', err);
|
||||
}
|
||||
await this.#runDrainStartHooks(info);
|
||||
this.#server.close(async () => {
|
||||
console.log(
|
||||
'PuterServer has stopped accepting new connections',
|
||||
);
|
||||
await this.#runPrepareShutdownHooks();
|
||||
try {
|
||||
await this.#runPrepareShutdownHooks();
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[server] prepare-shutdown hooks failed',
|
||||
err,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,17 +27,16 @@ import { PuterService } from '../types.js';
|
||||
*/
|
||||
export const RECOMMENDED_APP_NAMES = [
|
||||
'builder',
|
||||
'word-processor',
|
||||
'spreadsheet',
|
||||
'presentation',
|
||||
'whiteboard',
|
||||
'pdf-editor',
|
||||
'contacts',
|
||||
'calendar',
|
||||
'meetings',
|
||||
'teamchat',
|
||||
'email',
|
||||
'whiteboard',
|
||||
'spreadsheet',
|
||||
'word-processor',
|
||||
'presentation',
|
||||
'pdf-editor',
|
||||
'terminal',
|
||||
'dev-center',
|
||||
'cap-table',
|
||||
'invoices',
|
||||
|
||||
@@ -24,6 +24,7 @@ import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { WORKER_SUBDOMAIN_PREFIX } from '../../stores/subdomain/SubdomainStore.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { testJobContext } from '../jobs/jobsTestUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { EVENTS_WORKER_SESSION_NAME } from '../events/workerRuntime.js';
|
||||
import { FULL_API_ACCESS } from '../permission/consts.js';
|
||||
@@ -4048,7 +4049,6 @@ describe('AuthService (integration)', () => {
|
||||
});
|
||||
|
||||
describe('purgeEndedSessions', () => {
|
||||
const GATE_KEY = 'sessions:purge:gate';
|
||||
const DAY = 24 * 60 * 60;
|
||||
|
||||
const makeSession = async (
|
||||
@@ -4077,8 +4077,15 @@ describe('AuthService (integration)', () => {
|
||||
return rows.length > 0;
|
||||
};
|
||||
|
||||
const idOf = async (uuid: string) => {
|
||||
const [row] = await server.clients.db.read(
|
||||
'SELECT `id` FROM `sessions` WHERE `uuid` = ?',
|
||||
[uuid],
|
||||
);
|
||||
return Number(row.id);
|
||||
};
|
||||
|
||||
it('deletes rows ended past their retention and keeps the rest', async () => {
|
||||
await server.clients.redis.del(GATE_KEY);
|
||||
const user = await makeUser();
|
||||
const id = user.id;
|
||||
const oldRevoked = await makeSession(id, 'asset', 'revoked_at', 31);
|
||||
@@ -4100,7 +4107,7 @@ describe('AuthService (integration)', () => {
|
||||
const live = await makeSession(id, 'web');
|
||||
|
||||
expect(
|
||||
await authService.purgeEndedSessions(),
|
||||
await authService.purgeEndedSessions(testJobContext()),
|
||||
).toBeGreaterThanOrEqual(3);
|
||||
|
||||
expect(await exists(oldRevoked)).toBe(false);
|
||||
@@ -4112,34 +4119,84 @@ describe('AuthService (integration)', () => {
|
||||
expect(await exists(live)).toBe(true);
|
||||
});
|
||||
|
||||
it('skips while another run holds the gate', async () => {
|
||||
await server.clients.redis.del(GATE_KEY);
|
||||
await server.clients.redis.set(GATE_KEY, 'other-node');
|
||||
it('starts from the saved cursor and clears it once the walk is done', async () => {
|
||||
const user = await makeUser();
|
||||
const oldRevoked = await makeSession(
|
||||
user.id,
|
||||
'asset',
|
||||
'revoked_at',
|
||||
31,
|
||||
);
|
||||
const below = await makeSession(user.id, 'asset', 'revoked_at', 31);
|
||||
const above = await makeSession(user.id, 'asset', 'revoked_at', 31);
|
||||
const ctx = testJobContext({ afterId: await idOf(below) });
|
||||
|
||||
expect(await authService.purgeEndedSessions()).toBeNull();
|
||||
expect(await exists(oldRevoked)).toBe(true);
|
||||
await server.clients.redis.del(GATE_KEY);
|
||||
expect(
|
||||
await authService.purgeEndedSessions(ctx),
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
expect(await exists(below)).toBe(true);
|
||||
expect(await exists(above)).toBe(false);
|
||||
expect(ctx.saved).toEqual([null]);
|
||||
});
|
||||
|
||||
it('frees the gate when the walk fails', async () => {
|
||||
await server.clients.redis.del(GATE_KEY);
|
||||
const spy = vi
|
||||
.spyOn(server.clients.db, 'read')
|
||||
.mockRejectedValueOnce(new Error('db down'));
|
||||
it('clears its own checkpoints once a walk completes, so the next run starts at zero', async () => {
|
||||
const SPAN = 5000;
|
||||
vi.spyOn(server.stores.session, 'maxId').mockResolvedValue(
|
||||
SPAN * 11,
|
||||
);
|
||||
const ranges: number[] = [];
|
||||
const purge = vi
|
||||
.spyOn(server.stores.session, 'purgeEndedInRange')
|
||||
.mockImplementation(async (afterId: number) => {
|
||||
ranges.push(afterId);
|
||||
return 0;
|
||||
});
|
||||
try {
|
||||
await expect(authService.purgeEndedSessions()).rejects.toThrow(
|
||||
'db down',
|
||||
const first = testJobContext();
|
||||
await authService.purgeEndedSessions(first);
|
||||
expect(ranges).toHaveLength(11);
|
||||
// Checkpointed at ten ranges, then cleared at the end.
|
||||
expect(first.saved).toEqual([{ afterId: SPAN * 10 }, null]);
|
||||
|
||||
ranges.length = 0;
|
||||
await authService.purgeEndedSessions(
|
||||
testJobContext(first.saved.at(-1)),
|
||||
);
|
||||
expect(await server.clients.redis.get(GATE_KEY)).toBeNull();
|
||||
expect(ranges[0]).toBe(0);
|
||||
expect(ranges).toHaveLength(11);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
purge.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops between ranges once aborted, and the next run resumes at the saved cursor', async () => {
|
||||
const SPAN = 5000;
|
||||
vi.spyOn(server.stores.session, 'maxId').mockResolvedValue(
|
||||
SPAN * 30,
|
||||
);
|
||||
const ranges: number[] = [];
|
||||
const first = testJobContext();
|
||||
const purge = vi
|
||||
.spyOn(server.stores.session, 'purgeEndedInRange')
|
||||
.mockImplementation(async (afterId: number) => {
|
||||
ranges.push(afterId);
|
||||
// Aborted mid-walk, as a lost lease or a shutdown would.
|
||||
if (ranges.length === 12) first.abort();
|
||||
return 0;
|
||||
});
|
||||
try {
|
||||
await authService.purgeEndedSessions(first);
|
||||
expect(ranges).toHaveLength(12);
|
||||
// Saved every 10 ranges, and where it stopped.
|
||||
expect(first.saved).toEqual([
|
||||
{ afterId: SPAN * 10 },
|
||||
{ afterId: SPAN * 12 },
|
||||
]);
|
||||
|
||||
ranges.length = 0;
|
||||
const next = testJobContext(first.saved.at(-1));
|
||||
await authService.purgeEndedSessions(next);
|
||||
expect(ranges[0]).toBe(SPAN * 12);
|
||||
expect(ranges.at(-1)).toBe(SPAN * 29);
|
||||
expect(next.saved.at(-1)).toBeNull();
|
||||
} finally {
|
||||
purge.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ import type { puterServices } from '../index';
|
||||
import { EVENTS_WORKER_SESSION_NAME } from '../events/workerRuntime.js';
|
||||
import { FULL_API_ACCESS, PERMISSION_MAX_LEN } from '../permission/consts';
|
||||
import type { GrantMeta } from '../permission/PermissionService.js';
|
||||
import { savedAfterId, type JobRunContext } from '../jobs/JobScheduler.js';
|
||||
import { PuterService } from '../types';
|
||||
import type {
|
||||
AccessTokenPayload,
|
||||
@@ -64,13 +65,14 @@ const MAX_ORIGIN_UID_GENERATIONS = 8;
|
||||
// rows double as sign-in history, so they stay longer.
|
||||
const SESSION_RETENTION_SECONDS = 30 * 24 * 60 * 60;
|
||||
const WEB_SESSION_RETENTION_SECONDS = 365 * 24 * 60 * 60;
|
||||
// Every node checks hourly; the gate lets one node through per week.
|
||||
const SESSION_PURGE_CHECK_MS = 60 * 60 * 1000;
|
||||
const SESSION_PURGE_GATE_KEY = 'sessions:purge:gate';
|
||||
const SESSION_PURGE_GATE_SECONDS = 7 * 24 * 60 * 60;
|
||||
const SESSION_PURGE_JOB = 'auth.sessions.purge';
|
||||
const SESSION_PURGE_EVERY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const SESSION_PURGE_LEASE_MS = 30 * 60 * 1000;
|
||||
// Ids per DELETE, with a pause between, so replicas keep up.
|
||||
const SESSION_PURGE_SPAN = 5000;
|
||||
const SESSION_PURGE_PAUSE_MS = 50;
|
||||
/** Ranges walked between saves of the cursor. */
|
||||
const SESSION_PURGE_SAVE_EVERY = 10;
|
||||
|
||||
const nowSeconds = (): number => Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -138,11 +140,17 @@ export interface AuthResult {
|
||||
export class AuthService extends PuterService {
|
||||
declare protected services: LayerInstances<typeof puterServices>;
|
||||
|
||||
#sessionPurge: ReturnType<typeof setInterval> | null = null;
|
||||
#stopping = false;
|
||||
|
||||
override onServerStart(): void {
|
||||
this.#armSessionPurge();
|
||||
this.services.jobs.register(SESSION_PURGE_JOB, {
|
||||
every: SESSION_PURGE_EVERY_MS,
|
||||
scope: 'fleet',
|
||||
nearPrimary: true,
|
||||
leaseMs: SESSION_PURGE_LEASE_MS,
|
||||
run: async (ctx) => {
|
||||
const purged = await this.purgeEndedSessions(ctx);
|
||||
console.log(`[auth] purged ${purged} ended sessions`);
|
||||
},
|
||||
});
|
||||
|
||||
// Users implicitly hold read access to their own email — needed for
|
||||
// any permission-gated path that asks for `user:<uuid>:email:read`
|
||||
@@ -164,85 +172,54 @@ export class AuthService extends PuterService {
|
||||
});
|
||||
}
|
||||
|
||||
override onServerPrepareShutdown(): void {
|
||||
this.#stopping = true;
|
||||
if (this.#sessionPurge) clearInterval(this.#sessionPurge);
|
||||
this.#sessionPurge = null;
|
||||
}
|
||||
|
||||
// -- Session purge -----------------------------------------------
|
||||
|
||||
/**
|
||||
* Delete sessions revoked or expired longer ago than their retention,
|
||||
* walking the table by id. Returns the rows deleted, or null when another
|
||||
* node already holds this week's gate.
|
||||
* walking the table by id from the cursor an interrupted run saved. Returns
|
||||
* the rows deleted; stops early once `signal` aborts.
|
||||
*/
|
||||
async purgeEndedSessions(): Promise<number | null> {
|
||||
const claimed = await this.clients.redis.set(
|
||||
SESSION_PURGE_GATE_KEY,
|
||||
String(Date.now()),
|
||||
'EX',
|
||||
SESSION_PURGE_GATE_SECONDS,
|
||||
'NX',
|
||||
);
|
||||
if (claimed !== 'OK') return null;
|
||||
|
||||
async purgeEndedSessions({
|
||||
signal,
|
||||
state,
|
||||
saveState,
|
||||
}: JobRunContext): Promise<number> {
|
||||
const now = nowSeconds();
|
||||
const cutoffs = {
|
||||
cutoff: now - SESSION_RETENTION_SECONDS,
|
||||
webCutoff: now - WEB_SESSION_RETENTION_SECONDS,
|
||||
};
|
||||
const maxId = await this.stores.session.maxId();
|
||||
let afterId = savedAfterId(state);
|
||||
let purged = 0;
|
||||
let finished = false;
|
||||
try {
|
||||
const maxId = await this.stores.session.maxId();
|
||||
for (
|
||||
let afterId = 0;
|
||||
afterId < maxId;
|
||||
afterId += SESSION_PURGE_SPAN
|
||||
) {
|
||||
if (this.#stopping) return purged;
|
||||
purged += await this.stores.session.purgeEndedInRange(
|
||||
afterId,
|
||||
SESSION_PURGE_SPAN,
|
||||
cutoffs,
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, SESSION_PURGE_PAUSE_MS),
|
||||
);
|
||||
}
|
||||
finished = true;
|
||||
return purged;
|
||||
} finally {
|
||||
// A walk cut short frees the gate, so another node restarts it on
|
||||
// its next check instead of next week.
|
||||
if (!finished) {
|
||||
let unsaved = 0;
|
||||
while (afterId < maxId) {
|
||||
if (signal.aborted) {
|
||||
// Kept for the next run if this one still holds the job.
|
||||
try {
|
||||
await this.clients.redis.del(SESSION_PURGE_GATE_KEY);
|
||||
await saveState({ afterId });
|
||||
} catch {
|
||||
// The gate expires on its own.
|
||||
// Lost the job; the next run redoes these ranges.
|
||||
}
|
||||
return purged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#armSessionPurge(): void {
|
||||
const timer = setInterval(() => {
|
||||
void this.#runSessionPurge();
|
||||
}, SESSION_PURGE_CHECK_MS);
|
||||
timer.unref?.();
|
||||
this.#sessionPurge = timer;
|
||||
}
|
||||
|
||||
async #runSessionPurge(): Promise<void> {
|
||||
try {
|
||||
const purged = await this.purgeEndedSessions();
|
||||
if (purged !== null) {
|
||||
console.log(`[auth] purged ${purged} ended sessions`);
|
||||
purged += await this.stores.session.purgeEndedInRange(
|
||||
afterId,
|
||||
SESSION_PURGE_SPAN,
|
||||
cutoffs,
|
||||
);
|
||||
afterId += SESSION_PURGE_SPAN;
|
||||
if (++unsaved >= SESSION_PURGE_SAVE_EVERY) {
|
||||
await saveState({ afterId });
|
||||
unsaved = 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[auth] session purge failed', err);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, SESSION_PURGE_PAUSE_MS),
|
||||
);
|
||||
}
|
||||
// Clears any checkpoint, this run's included, so next week starts at 0.
|
||||
await saveState(null);
|
||||
return purged;
|
||||
}
|
||||
|
||||
// -- Public API --------------------------------------------------
|
||||
|
||||
Loaded 100 of 595 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user