Compare commits

...
Author SHA1 Message Date
Reynaldi ChernandoandClaude Opus 5.5 fec1d1916f Merge remote-tracking branch 'upstream/main' into codex/openai-image-default
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 01:03:08 +07:00
Juan Fernando CastroandReynaldi Chernando da369039e0 docs: complex teams recipe (PUT-1986) (#4095)
* docs: complex teams recipe (PUT-1986)

Showing which teammates have a file open, built from a folder shared with the
team plus `puter.events` watching it. There is no presence API, so the recipe
is about composing the two.

One use case per the recipe guidance. Uses the masked path from
`listShared()` so members address the owner's folder, keys presence files on
`uuid` rather than username, and re-reads the folder on each event so a missed
delivery cannot leave the list wrong.

* refine

---------

Co-authored-by: Reynaldi Chernando <[email protected]>
2026-11-06 00:53:25 +07:00
Neal Shah 64c3b3c0ce subdomain and worker recipes complex, writing full stack applications with both (#4206) 2026-11-05 21:33:21 +07:00
Reynaldi Chernando e9a4c7a461 ai docs cleanup (#4504)
* ai docs cleanup

* fixes
2026-11-05 19:15:32 +07:00
Filip Kujundžić be9cd9eb89 feat(ai): add GPT Image 2.6 and default to Flare 2026-11-05 11:13:43 +01:00
Daniel Salazar 8b64cc5076 docs(notifications): recipes and format pass for dismissal, priority, buttons and schedule updates (#4502)
- dismiss.md is the one place that explains closing a notification on other
  devices; on/onNotification/push link to it instead of repeating it.
- Pages follow the house format: section order, error tables, playground
  examples for dismiss() and markShown(), sidebar and index listings.
- updateSchedule.md describes what a developer sees instead of internals.
- New recipes: mark notifications as read, incoming calls (high priority,
  short ttl, buttons), unread counts on the app icon.
- Existing recipes move reminders, snoozes and recurring summaries with
  updateSchedule() instead of cancel-and-recreate; the buttons recipe follows
  the recipe format.
2026-11-05 00:32:53 -07:00
Daniel Salazar d3869b7846 feat(notifications): dismissal across devices, high priority, buttons, schedule updates, docs fixes (#4501)
- Docs: status(), browser limits, events worker fetch/ack, update() across
  pages, stored-notification retries, device removal, push delivery, queue
  coalescing, existing service workers and missing error codes match the code.
- puter.notifications.markShown(uid) / dismiss(uid).
- Dismissals reach the app's live feed (on('dismiss')); pages close the
  notification on screen, and a later page load closes ones already dismissed.
  The desktop closes its own copies on notif.ack.
- Live notification postings are forwarded to sessions in other regions.
- notify({ priority: 'high' }).
- onPush() results accept requireInteraction and actions; clicks carry action.
- replaced reaches service-worker onPush() handlers.
- puter.events.updateSchedule(scheduleId, changes), with the invoker
  re-checking a schedule run's guard right before it is sent.
2026-11-05 00:17:27 -07:00
Daniel Salazar 91230697cd fix(backend): shutdown hooks don't skip each other, and final flushes wait for in-flight work (#4499) (#4500)
A throwing prepare/shutdown hook stopped the loop, so shutdown() skipped
closeAllConnections and every later hook, including the final metering
flush; each hook now runs and failures are logged. Broadcast, events
forwarding and share digests joined nothing already in flight and could
drop what was queued behind it; their final flushes now wait for it, ship
the tail, and drain once more at shutdown. Local worker disposal and the
OpenAI image metering call are awaited. settleWithin and
FINAL_FLUSH_TIMEOUT_MS bound each final flush.
2026-11-04 21:47:35 -07:00
Daniel Salazar 56e4856518 feat(backend): drain-start hooks run concurrently and can hold the listener open (#4498)
The listener closes once every onServerDrainStart hook settles, and the
drain window now starts at the signal, so time a hook spends holding the
listener open counts against drainMs instead of extending shutdown. A
failing hook no longer skips the others.
2026-11-04 19:40:54 -07:00
Reynaldi Chernando 8acd73675c use gpt 6 luna as example in docs (#4497) 2026-11-05 08:20:12 +07:00
Nariman Jelveh fc4af97885 fix(gui): the custom domain empty state holds its add button
With no domains, the only way on was the add button in the footer, which
the full-page popup pins to the bottom edge, far from the empty state the
eye lands on. The button now sits in the empty state under its hint, and
the footer keeps only Cancel until there's a domain to list.
2026-11-04 17:08:06 -07:00
Nariman Jelveh 7cec710014 feat(gui): redesign the custom domain dialog, full page in its popup (#4496)
* feat(gui): redesign the custom domain dialog, full page in its popup

- Domains list as cards with a status badge, trash icon, and inline
  Cancel/Confirm for remove and move
- DNS records as cards with copy buttons that confirm with a check
- Status card on the records step; a green check once the domain serves
  the site, after which the dialog closes
- Spinners on the button that's working, skeleton while loading, empty
  and unavailable states
- Pasted URLs are trimmed to their hostname before adding
- In the attach-custom-domain popup the dialog fills the page, with the
  actions pinned to the bottom

* fix(gui): Escape backs out of removing the chosen custom domain

Escape already put back a list row's pending confirmation. On the records
step, it closed the dialog while Remove was asking to confirm; it now puts
the button back first, like the rows.

* fix(gui): each custom domain step opens scrolled to the top

Switching steps reset the scroll of the dialog's body, which only scrolls
when the dialog fills a popup. The floating modal scrolls itself, so a
step opened from far down the list started scrolled past its status card.

* fix(gui): keyboard focus stays in the custom domain dialog

Focus on a control that got hidden or disabled fell to the page behind the
dialog, so Tab started over from there. Switching steps now focuses the
dialog, backing out of a row's confirmation returns to the button that
asked, a request gives focus back to the button that started it, and
removing a row moves focus to the next one.

* fix(gui): a removed custom domain row takes no input as it fades

Its buttons came back on once the delete finished, so a second click in
the fade sent the delete again, and Cancel brought back Use for a domain
that no longer exists. The row is inert until it's gone.

* fix(gui): DNS record copy buttons work without the clipboard API

On an origin with no navigator.clipboard (a Puter served over plain http)
or when the browser refuses the write, the copy buttons did nothing. They
now select the value and try the legacy copy, like the dashboard's form
dialog, and leave it selected to copy by hand if that fails too.

* fix(gui): custom domain rows keep the icon beside the name on phones

The name was sized to the row less 46px for the icon and gap, but the icon
is 36px wide with its border, so the row overflowed by 2px and every name
dropped below its icon. Only the actions wrap now; the name takes the rest
of the first line.

* fix(gui): hostnames in the custom domain dialog wrap whole

Every bold hostname broke at any letter, so one that would fit on the
next line was split mid-word at the end of this one ("puter.lo / calhost").
It now moves down whole and breaks only when it's wider than the line.

* fix(gui): readable Remove and Confirm buttons in dark mode

The red fill under their white labels used the danger text color, which
dark mode lightens to #f87171: 2.8:1 against white. Fills now use their
own token that stays #dc2626 (4.8:1); light mode is unchanged.

* fix(gui): the switch-to-www link shows when it's working

The link that switches a root domain to www had no disabled or loading
style, so while its request ran it still looked clickable and showed
nothing, while every other control dimmed. It now dims like them when
waiting and shows a spinner when it's the one working.
2026-11-04 16:45:56 -07:00
Daniel Salazar 48cd4ea355 fix: oidc toggle (#4494) 2026-11-04 13:13:15 -07:00
Neal ShahandClaude Opus 5.5 a110be0340 feat(gui): remove a custom domain from the attach dialog (#4493)
Each domain in the list gets a Remove button, confirmed in place like Move,
and the records pane can remove the domain just added. A removed domain is
never handed back as the dialog's answer.

Also fixes two strings that went stale: the claim prompt (an active domain
can be claimed too now) and the redirect note (root domains get two A
records).

PUT-2338

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 13:23:04 -05:00
158 changed files with 8204 additions and 1269 deletions

No files matched your search

+27
View File
@@ -161,11 +161,38 @@ An overview page answers "what is this API and is it what I need?", and a functi
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 `).
### 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.
@@ -318,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 }]);
});
+16 -7
View File
@@ -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. */
+20
View File
@@ -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,6 +597,8 @@ 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. */
@@ -347,6 +347,46 @@ describe('DispatcherInvokeTransport with every connection busy', () => {
held[INVOKE_CONNECTIONS].answer();
await expect(after).resolves.toMatchObject({ status: 200 });
});
it('rechecks the guard once a connection frees, and drops a superseded call', async () => {
const { transport, held, answerAll } = await saturated();
let current = true;
const guard = vi.fn(async () => current);
const waiting = transport.send({ ...CALL, beforeSend: guard });
current = false;
held[0].answer();
await expect(waiting).resolves.toEqual({
status: null,
error: 'invocation superseded',
});
expect(guard).toHaveBeenCalledTimes(1);
expect(held).toHaveLength(INVOKE_CONNECTIONS);
await answerAll();
});
it('fails a call whose guard throws after the wait, and frees its slot', async () => {
const { transport, held, answerAll } = await saturated();
const waiting = transport.send({
...CALL,
beforeSend: async () => {
throw new Error('lease store down');
},
});
held[0].answer();
await expect(waiting).rejects.toThrow('lease store down');
expect(held).toHaveLength(INVOKE_CONNECTIONS);
await answerAll();
const after = transport.send(CALL);
await vi.waitFor(() =>
expect(held).toHaveLength(INVOKE_CONNECTIONS + 1),
);
held[INVOKE_CONNECTIONS].answer();
await expect(after).resolves.toMatchObject({ status: 200 });
});
});
describe('DispatcherInvokeTransport schedule lane', () => {
@@ -771,6 +811,138 @@ describe('DispatcherInvokeTransport batches', () => {
]);
});
it('leaves a call superseded while gathered out of the batch', async () => {
const { fetchImpl, batches, singles } = streamingFetch();
const transport = transportFor(fetchImpl, { batchWindowMs: 20 });
let current = true;
const sent = [
transport.send(CALL),
transport.send({ ...CALL, beforeSend: async () => current }),
transport.send({ ...CALL, script: 'evw-other' }),
];
current = false;
await vi.waitFor(() => expect(batches).toHaveLength(1));
expect(batches[0].body.calls.map((call) => call.script)).toEqual([
'evw-test',
'evw-other',
]);
await expect(sent[1]).resolves.toEqual({
status: null,
error: 'invocation superseded',
});
batches[0].answer({ i: 0, status: 200, handled: true });
batches[0].answer({ i: 1, status: 200, handled: true });
await expect(Promise.all([sent[0], sent[2]])).resolves.toEqual([
{ status: 200, handled: true },
{ status: 200, handled: true },
]);
expect(singles).toHaveLength(0);
});
it('sends nothing when every gathered call was superseded', async () => {
const fetchImpl = vi.fn(
stubFetch(200, { [EVENTS_HANDLED_HEADER]: '1' }),
);
const transport = transportFor(fetchImpl as unknown as FetchImpl);
const superseded = { ...CALL, beforeSend: async () => false };
await expect(
Promise.all([
transport.send(superseded),
transport.send(superseded),
]),
).resolves.toEqual([
{ status: null, error: 'invocation superseded' },
{ status: null, error: 'invocation superseded' },
]);
expect(fetchImpl).not.toHaveBeenCalled();
});
it('fails only the call whose guard throws at flush', async () => {
const { fetchImpl, batches, singles } = streamingFetch();
const transport = transportFor(fetchImpl);
const failing = transport.send({
...CALL,
beforeSend: async () => {
throw new Error('lease store down');
},
});
const kept = transport.send(CALL);
await expect(failing).rejects.toThrow('lease store down');
// One call left, so it goes alone.
await expect(kept).resolves.toEqual({ status: 200, handled: true });
expect(batches).toHaveLength(0);
expect(singles).toHaveLength(1);
});
it('rechecks the guard before resending a batch call on its own', async () => {
const urls: string[] = [];
let current = true;
const fetchImpl = (async (url: string) => {
urls.push(url);
if (url.endsWith(EVENTS_DISPATCH_BATCH_PATH)) {
// Superseded while the batch was out.
current = false;
return new Response('no-such-route', {
status: 404,
headers: {
[EVENTS_DISPATCH_ERROR_HEADER]: 'no-such-route',
},
});
}
return new Response(null, {
status: 200,
headers: { [EVENTS_HANDLED_HEADER]: '1' },
});
}) as unknown as FetchImpl;
const transport = transportFor(fetchImpl);
await expect(
Promise.all([
transport.send({ ...CALL, beforeSend: async () => current }),
transport.send(CALL),
]),
).resolves.toEqual([
{ status: null, error: 'invocation superseded' },
{ status: 200, handled: true },
]);
expect(urls.map((url) => new URL(url).pathname)).toEqual([
EVENTS_DISPATCH_BATCH_PATH,
EVENTS_DISPATCH_PATH,
]);
});
it('answers a schedule run superseded in the batch as the early check does', async () => {
const { fetchImpl, batches } = streamingFetch();
const client = new EventsWorkerInvokerClient({
events: { invokeTimeoutMs: 5_000 },
} as unknown as IConfig);
client.setTransport(transportFor(fetchImpl, { batchWindowMs: 20 }));
let current = true;
const guard = vi.fn(async () => current);
const run = client.invoke(REQUEST, {
lane: 'schedule',
beforeSend: guard,
});
const other = client.invoke(REQUEST);
const another = client.invoke(REQUEST);
await vi.waitFor(() => expect(guard).toHaveBeenCalledTimes(1));
current = false;
await vi.waitFor(() => expect(batches).toHaveLength(1));
expect(batches[0].body.calls).toHaveLength(2);
await expect(run).resolves.toEqual(
await client.invoke(REQUEST, { beforeSend: async () => false }),
);
await expect(run).resolves.toMatchObject({
outcome: 'unavailable',
error: 'invocation superseded',
});
batches[0].answer({ i: 0, status: 200, handled: true });
batches[0].answer({ i: 1, status: 200, handled: true });
await Promise.all([other, another]);
});
it('builds each body when the batch goes out', async () => {
const { fetchImpl, batches } = streamingFetch();
const transport = transportFor(fetchImpl);
@@ -827,6 +999,26 @@ describe('DispatcherInvokeTransport batches', () => {
});
describe('EventsWorkerInvokerClient invoke options', () => {
it('does not send a superseded invocation', async () => {
const client = makeClient();
const send = vi.fn(async () => ({ status: 200, handled: true }));
client.setTransport({ send });
const result = await client.invoke(REQUEST, { beforeSend: async () => false });
expect(result.outcome).toBe('unavailable');
expect(send).not.toHaveBeenCalled();
});
it('rechecks before resending after a deployment', async () => {
const client = makeClient();
let current = true;
const send = vi.fn(async () => ({ status: null, dispatchReason: 'missing' }));
client.setTransport({ send });
client.setMissHandler(async () => { current = false; return 'deployed'; });
const result = await client.invoke(REQUEST, { beforeSend: async () => current });
expect(result.outcome).toBe('unavailable');
expect(send).toHaveBeenCalledTimes(1);
});
it('stamps the event as each attempt goes out', async () => {
const bodies: string[] = [];
const client = makeClient();
@@ -282,6 +282,11 @@ export interface EventsInvokeCall {
* 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. */
@@ -334,6 +339,8 @@ export interface WorkerInvokeResult {
/** 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;
}
@@ -341,6 +348,12 @@ export interface WorkerInvokeOptions {
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;
@@ -395,11 +408,13 @@ export class EventsWorkerInvokerClient extends PuterClient {
const margin = Math.min(EVENTS_DEADLINE_MARGIN_MS, timeoutMs / 2);
const deadline =
Date.now() + timeoutMs * EVENTS_WORKER_LEASE_FACTOR - margin;
const send = (
const send = async (
deployed: boolean,
sendTimeoutMs = timeoutMs,
): Promise<EventsInvokeAnswer> =>
transport.send({
): Promise<EventsInvokeAnswer> => {
if (options.beforeSend && !(await options.beforeSend()))
return supersededAnswer();
return transport.send({
script: request.script,
appUid: request.appUid,
...(request.holder ? { holder: request.holder } : {}),
@@ -409,7 +424,11 @@ export class EventsWorkerInvokerClient extends PuterClient {
...(deployed ? { deployed: true } : {}),
...(options.lane ? { lane: options.lane } : {}),
...(stampEvent ? { render } : {}),
...(options.beforeSend
? { beforeSend: options.beforeSend }
: {}),
});
};
// 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.
@@ -534,6 +553,8 @@ export class EventsWorkerInvokerClient extends PuterClient {
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;
}
@@ -604,9 +625,14 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
error: 'no free connection to the events dispatcher',
};
try {
if (this.#batchSize === 1) return await this.#sendOne(call);
return await new Promise<EventsInvokeAnswer>((resolve) => {
this.#batch.push({ call, resolve, waitingSince });
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(
@@ -626,6 +652,12 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
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);
@@ -634,6 +666,26 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
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 {
@@ -688,13 +740,15 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
let open = batch.length;
const answered = batch.map(() => false);
const timers: ReturnType<typeof setTimeout>[] = [];
const settle = (i: number, answer: EventsInvokeAnswer): void => {
const finish = (i: number, done: (entry: BatchedCall) => void) => {
if (answered[i]) return;
answered[i] = true;
clearTimeout(timers[i]);
batch[i].resolve(answer);
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);
};
@@ -777,8 +831,22 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
this.#batchUnsupportedUntil =
Date.now() + BATCH_UNSUPPORTED_RETRY_MS;
batch.forEach(({ call }, i) => {
if (!answered[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;
}
@@ -236,6 +236,17 @@ export class EventsController extends PuterController {
});
}
@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.
@@ -75,6 +75,7 @@ const fakeSchedules = () =>
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(),
@@ -129,6 +130,10 @@ const routes: Array<[string, (response: Response) => Promise<void>]> = [
(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) =>
@@ -250,6 +255,16 @@ describe('with services.schedules', () => {
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);
@@ -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);
});
});
@@ -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 });
},
);
+9 -1
View File
@@ -170,8 +170,16 @@ export interface ScheduleServiceContract {
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>;
receivePeerChanges(body: unknown): Promise<{ applied: number }>;
/** `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(
@@ -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({
@@ -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', () => {
@@ -235,6 +235,56 @@ 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) => {
if (!blocked && args[5].includes(`:actor:${actor.user!.uuid}:`)) {
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);
@@ -857,3 +907,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',
@@ -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' },
});
@@ -165,6 +165,7 @@ const fakeSchedules = (plan: Partial<DeferredSendPlan>) =>
createFor: vi.fn(),
list: vi.fn(),
get: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
receivePeerChanges: vi.fn(),
prepareSendAt: vi.fn(async (): Promise<DeferredSendPlan> => ({
@@ -270,7 +271,9 @@ describe('notify({ sendAt }) with services.schedules', () => {
const schedules = fakeSchedules({ sendAt: at });
setSchedules(schedules);
await expect(notify({ sendAt: at, text: 'later' })).resolves.toEqual({
await expect(
notify({ sendAt: at, text: 'later', priority: 'high' }),
).resolves.toEqual({
scheduled: { scheduleId: 'sched-1', sendAt: at },
});
expect(schedules.prepareSendAt).toHaveBeenCalledWith(
@@ -283,6 +286,7 @@ describe('notify({ sendAt }) with services.schedules', () => {
message: expect.objectContaining({
title: 'Hi',
text: 'later',
priority: 'high',
}),
},
);
@@ -318,11 +322,20 @@ describe('deferredAppMessages.write', () => {
const key = uuidv4();
const id = await claimed(token);
await expect(write(key, txFor(id, token))).resolves.toBe('delivered');
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' });
expect(valueOf(rows[0])).toMatchObject({
title: 'Later',
priority: 'high',
});
});
it('writes nothing when the guard no longer holds: another claim took it', async () => {
@@ -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,7 +46,7 @@ import {
ownedAppUids,
} from '../../services/notification/notificationAudience.js';
import {
NOTIFICATION_VALUE_MAX_BYTES,
assertNotificationValueFits,
OUTSIDE_ACCOUNT_MAILBOX_TYPES,
resolveNotificationWrite,
type NotificationTypeName,
@@ -120,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',
@@ -277,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}`);
}
@@ -342,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.
@@ -430,7 +438,7 @@ 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
@@ -539,9 +547,7 @@ export class NotificationDriver extends PuterDriver {
};
}
/**
* For extensions. A field, not a method, so `/drivers/call` can't reach it.
*/
/** 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),
+165
View File
@@ -1492,6 +1492,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, {
@@ -1541,6 +1639,73 @@ 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', () => {
+47 -13
View File
@@ -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,11 +1755,47 @@ 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,
);
}
}
}
}
/**
* 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;
/**
@@ -1782,16 +1813,19 @@ export class PuterServer {
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,
);
}
});
}
}
@@ -1149,3 +1149,154 @@ describe('BroadcastService — same-cluster pub/sub fan-out', () => {
}
});
});
describe('BroadcastService shutdown drain', () => {
let server: PuterServer;
let broadcast: BroadcastService;
beforeEach(async () => {
axiosRequestMock.mockReset();
axiosRequestMock.mockResolvedValue({ status: 200, data: 'ok' });
server = await setupTestServer({
broadcast: {
webhook: { peerId: SELF_PEER_ID, secret: SELF_SECRET },
peers: [
{
peerId: PEER_ID,
webhook: true,
webhook_url: PEER_URL,
webhook_secret: PEER_SECRET,
},
],
outbound_flush_ms: 10,
},
} as never);
broadcast = server.services.broadcast as unknown as BroadcastService;
});
afterEach(async () => {
vi.useRealTimers();
await broadcast.onServerPrepareShutdown();
await broadcast.onServerShutdown();
await server.shutdown();
});
const emit = (hash: string) =>
server.clients.event.emit(
'outer.fs.write-hash' as never,
{ hash, uuid: hash } as never,
{},
);
it.each(['onServerPrepareShutdown', 'onServerShutdown'] as const)(
'waits for the active flush and sends its tail in %s',
async (hook) => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
let releaseTail!: () => void;
const tailHeld = new Promise<void>((resolve) => {
releaseTail = resolve;
});
axiosRequestMock
.mockImplementationOnce(async () => {
await held;
return { status: 200, data: 'ok' };
})
.mockImplementationOnce(async () => {
await tailHeld;
return { status: 200, data: 'ok' };
});
emit('first');
await vi.waitFor(() =>
expect(axiosRequestMock).toHaveBeenCalledTimes(1),
);
emit('tail');
let finished = false;
const draining = broadcast[hook]().then(() => {
finished = true;
});
try {
await new Promise((resolve) => setTimeout(resolve, 20));
expect(finished).toBe(false);
release();
await vi.waitFor(() =>
expect(axiosRequestMock).toHaveBeenCalledTimes(2),
);
expect(finished).toBe(false);
} finally {
release();
releaseTail();
await draining;
}
expect(axiosRequestMock).toHaveBeenCalledTimes(2);
expect(axiosRequestMock.mock.calls[1][0].data).toContain('tail');
},
);
it('keeps normal timer flushing after prepare on an idle server', async () => {
await broadcast.onServerPrepareShutdown();
vi.useFakeTimers();
axiosRequestMock.mockClear();
emit('during-drain');
await vi.advanceTimersByTimeAsync(10);
expect(axiosRequestMock).toHaveBeenCalledTimes(1);
expect(axiosRequestMock.mock.calls[0][0].data).toContain(
'during-drain',
);
});
it('sends events queued after prepare during shutdown and clears the timer', async () => {
await broadcast.onServerPrepareShutdown();
vi.useFakeTimers();
axiosRequestMock.mockClear();
emit('after-prepare');
await broadcast.onServerShutdown();
expect(axiosRequestMock).toHaveBeenCalledTimes(1);
expect(axiosRequestMock.mock.calls[0][0].data).toContain(
'after-prepare',
);
axiosRequestMock.mockClear();
await vi.advanceTimersByTimeAsync(100);
expect(axiosRequestMock).not.toHaveBeenCalled();
});
it.each(['onServerPrepareShutdown', 'onServerShutdown'] as const)(
'bounds a hung flush in %s',
async (hook) => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
axiosRequestMock.mockImplementationOnce(async () => {
await held;
return { status: 200, data: 'ok' };
});
emit('hung');
await vi.waitFor(() =>
expect(axiosRequestMock).toHaveBeenCalledTimes(1),
);
vi.useFakeTimers();
emit('tail');
let finished = false;
const draining = broadcast[hook]().then(() => {
finished = true;
});
try {
await vi.advanceTimersByTimeAsync(4_999);
expect(finished).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await draining;
release();
await vi.advanceTimersByTimeAsync(100);
expect(axiosRequestMock).toHaveBeenCalledTimes(
hook === 'onServerPrepareShutdown' ? 2 : 1,
);
} finally {
release();
vi.useRealTimers();
}
},
);
});
@@ -21,6 +21,8 @@ import axios from 'axios';
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { Agent as HttpsAgent } from 'node:https';
import { IBroadcastPeerConfig } from '../../types.js';
import { settleWithin } from '../../util/concurrency.js';
import { FINAL_FLUSH_TIMEOUT_MS } from '../../util/gracefulShutdown.js';
import { PuterService } from '../types.js';
// -- Wire types ------------------------------------------------------
@@ -121,7 +123,8 @@ export class BroadcastService extends PuterService {
/** Per peer, serialized events it has not accepted yet, oldest first. */
#outboundBacklogs = new Map<IBroadcastPeerConfig, string[]>();
#outboundFlushTimer: ReturnType<typeof setTimeout> | null = null;
#outboundIsFlushing = false;
#outboundFlush: Promise<void> | null = null;
#outboundDraining = false;
#webhookReplayWindowSeconds = 300;
#outboundFlushMs = 2000;
@@ -165,21 +168,40 @@ export class BroadcastService extends PuterService {
}
override async onServerPrepareShutdown(): Promise<void> {
await this.#drainOutboundEvents();
if (this.#redisSub) {
try {
await this.#redisSub.unsubscribe('pubsub');
this.#redisSub.quit();
} catch (err) {
console.warn('[broadcast] unsubscribe failed', err);
} finally {
this.#redisSub = null;
}
}
}
override async onServerShutdown(): Promise<void> {
this.#outboundDraining = true;
await this.#drainOutboundEvents();
}
async #drainOutboundEvents(): Promise<void> {
if (this.#outboundFlushTimer) {
clearTimeout(this.#outboundFlushTimer);
this.#outboundFlushTimer = null;
}
// Best-effort drain — try one final flush so events queued near
// shutdown make it out.
try {
await this.#flushOutboundEvents();
} catch (err) {
console.warn('[broadcast] final flush failed', err);
}
if (this.#redisSub) {
await this.#redisSub.unsubscribe('pubsub');
this.#redisSub.quit();
this.#redisSub = null;
const deadline = Date.now() + FINAL_FLUSH_TIMEOUT_MS;
const drain = async () => {
try {
await this.#outboundFlush;
if (Date.now() < deadline) await this.#flushOutboundEvents();
} catch (err) {
console.warn('[broadcast] final flush failed', err);
}
};
if (!(await settleWithin(drain(), FINAL_FLUSH_TIMEOUT_MS))) {
console.warn('[broadcast] final flush timed out');
}
}
@@ -453,24 +475,27 @@ export class BroadcastService extends PuterService {
}
#scheduleOutboundFlush(): void {
if (this.#outboundFlushTimer) return;
this.#outboundFlushTimer = setTimeout(() => {
if (this.#outboundDraining || this.#outboundFlushTimer) return;
this.#outboundFlushTimer = setTimeout(async () => {
this.#outboundFlushTimer = null;
void this.#flushOutboundEvents().catch((err) => {
try {
await this.#flushOutboundEvents();
} catch (err) {
console.warn('[broadcast] outbound flush failed', err);
});
}
}, this.#outboundFlushMs);
}
async #flushOutboundEvents(): Promise<void> {
if (
this.#outboundIsFlushing ||
(this.#outboundQueue.size === 0 &&
this.#outboundBacklogs.size === 0)
)
return;
#flushOutboundEvents(): Promise<void> {
if (this.#outboundFlush) return this.#outboundFlush;
if (this.#outboundQueue.size === 0 && this.#outboundBacklogs.size === 0)
return Promise.resolve();
this.#outboundIsFlushing = true;
this.#outboundFlush = this.#sendOutboundEvents();
return this.#outboundFlush;
}
async #sendOutboundEvents(): Promise<void> {
try {
const fresh = this.#outboundQueue;
this.#outboundQueue = new Set();
@@ -504,7 +529,7 @@ export class BroadcastService extends PuterService {
for (const result of flushed)
if (result.status === 'rejected') throw result.reason;
} finally {
this.#outboundIsFlushing = false;
this.#outboundFlush = null;
// Anything that arrived during flush, or is still owed to a peer,
// gets the next tick.
if (
+107 -29
View File
@@ -56,6 +56,7 @@ import {
} from '../../core/actor.js';
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
import type {
SqlGuard,
ScheduleEndReason,
ScheduleSubscriptionChange,
} from '../../core/schedules.js';
@@ -246,6 +247,7 @@ import {
isNotifToken,
kvHandleFromSubject,
kvHandleMatchFromSubject,
notifAnchorToken,
notifMatchSpansEveryApp,
parseSubject,
type FsOp,
@@ -680,6 +682,7 @@ export interface KvDispatchOptions {
/** One persisted notification, as the bus reports it. */
export interface NotifDispatchInput {
dismissed?: true;
/** The recipient, whose mailbox is the anchor. */
userId: number;
userUuid: string;
@@ -915,7 +918,7 @@ const scheduleSubscription = (): HttpError =>
const scheduleImmutable = (): HttpError =>
new HttpError(
400,
"A schedule's context can't be changed; remove the schedule and create a new one",
"Use updateSchedule() to change a schedule's context or timing",
{ legacyCode: 'events_schedule_immutable' },
);
@@ -1664,6 +1667,26 @@ export class EventsService extends PuterService {
});
});
this.clients.event.on('notif.acknowledged', (_key, data) => {
if (!this.notificationsFoldIn) return;
void (async () => {
const user = await this.stores.user.getById(data.userId);
if (!user?.uuid) return;
await this.dispatchNotif({
...data,
userUuid: user.uuid,
value: {},
createdAt: Date.now(),
dismissed: true,
});
})().catch((err) => {
console.warn(
'[events] notification dismissal dispatch failed',
err,
);
});
});
this.#armPendingSweep();
if (this.enabled) this.#registerJobs();
}
@@ -2870,6 +2893,7 @@ export class EventsService extends PuterService {
override?: readonly SuspendedReason[];
/** What a schedule ended by this records instead of `reason`. */
scheduleEndReason?: ScheduleEndReason;
guard?: SqlGuard;
} = {},
): Promise<DurableSubscription[]> {
if (rows.length === 0) return [];
@@ -2877,6 +2901,7 @@ export class EventsService extends PuterService {
const { suspended, bumps } =
await this.stores.durableSubscription.suspend(rows, reason, {
override: opts.override,
guard: opts.guard,
});
const policy = backlogPolicyFor(reason);
for (const row of suspended) {
@@ -2992,8 +3017,14 @@ export class EventsService extends PuterService {
* retry path's; this is the state it drives, and the developer is told
* because it is their code that stopped working. Called by extensions.
*/
async suspendForFailures(subId: string): Promise<boolean> {
return this.#suspendOne(subId, 'failures', { notifyDeveloper: true });
async suspendForFailures(
subId: string,
guard?: SqlGuard,
): Promise<boolean> {
return this.#suspendOne(subId, 'failures', {
notifyDeveloper: true,
guard,
});
}
/**
@@ -3668,13 +3699,16 @@ export class EventsService extends PuterService {
async #suspendOne(
subId: string,
reason: SuspendedReason,
options: { notifyDeveloper: boolean },
options: { notifyDeveloper: boolean; guard?: SqlGuard },
): Promise<boolean> {
if (!this.enabled) return false;
const row = await this.stores.durableSubscription.getBySubId(subId);
if (!row || row.suspendedAt !== null) return false;
await this.suspendSubscriptions([row], reason);
const suspended = await this.#suspend([row], reason, {
guard: options.guard,
});
if (suspended.length === 0) return false;
if (options.notifyDeveloper && row.appUid && row.handlerName)
await this.#notifySuspended(scopeOfRow(row), row.handlerName, 1);
return true;
@@ -4554,27 +4588,42 @@ export class EventsService extends PuterService {
return { matched, tokens };
}
if (item.family === 'notif' && item.notif) {
const input = {
...item.notif,
userId: item.ownerUserId,
createdAt: item.ts,
};
const matched = await this.dispatchNotif(input, {
forwarded: true,
id: item.id,
});
return { matched, tokens: [notifAnchorToken(item.notif.userUuid)] };
}
return { matched: false, tokens: [] };
}
/**
* Publish one persisted notification: the desktop's wire first, because
* that is what a person is waiting on, then whatever subscriptions asked
* for this slice of the mailbox.
*
* The fan-out is one token — a notification is addressed to a person — so
* there is no tree to walk and nothing to coalesce against.
* Publish a posting or dismissal to the same mailbox audience. Postings
* also reach the desktop's wire; forwarded copies evaluate session rows
* only.
*/
async dispatchNotif(input: NotifDispatchInput): Promise<void> {
if (!this.notificationsFoldIn) return;
async dispatchNotif(
input: NotifDispatchInput,
options: { forwarded?: boolean; id?: string } = {},
): Promise<boolean> {
if (!this.notificationsFoldIn) return false;
const subject = lookupNotifSubject('notif.created');
if (!subject) return;
if (!subject) return false;
const userId = input?.userId;
if (typeof userId !== 'number' || !input.userUuid || !input.uid) return;
if (typeof userId !== 'number' || !input.userUuid || !input.uid)
return false;
const context: NotifEventContext = {
key: 'notif.created',
key: input.dismissed ? 'notif.acknowledged' : 'notif.created',
...(input.dismissed ? { dismissed: true as const } : {}),
userId,
userUuid: input.userUuid,
uid: input.uid,
@@ -4582,9 +4631,9 @@ export class EventsService extends PuterService {
audience: (input.audience ??
'account') as NotifEventContext['audience'],
appUid: input.appUid ?? null,
notification: input.value ?? {},
notification: input.dismissed ? {} : (input.value ?? {}),
...(input.replaced === true ? { replaced: true as const } : {}),
id: input.uid,
id: options.id ?? (input.dismissed ? randomUUID() : input.uid),
ts: input.createdAt || Date.now(),
handlerDepth: input.handlerDepth,
};
@@ -4592,29 +4641,51 @@ export class EventsService extends PuterService {
// The adapter is not a subscription: the desktop is delivered to
// whether or not anything subscribed, which is what keeps the wire
// contract identical with the fold-in on.
this.services.notification.deliverOverSocket(context);
if (!input.dismissed && !options.forwarded)
this.services.notification.deliverOverSocket(context);
if (!(await this.#userHasAny(userId))) return;
if (!(await this.#userHasAny(userId))) return false;
const watched = await this.stores.eventSubscription.watchedTokens(
userId,
subject.tokens(context),
);
if (watched.length === 0) return;
const { local, remote } =
await this.stores.eventSubscription.watchedFor(
userId,
subject.tokens(context),
);
if (!options.forwarded && remote.size > 0)
this.services.eventForward.forwardEvent(regionsIn(remote), {
family: 'notif',
ownerUserId: userId,
id: context.id,
ts: context.ts,
notif: {
userUuid: input.userUuid,
uid: input.uid,
type: context.type,
audience: context.audience,
appUid: context.appUid,
value: context.notification,
...(input.dismissed ? { dismissed: true as const } : {}),
...(input.replaced ? { replaced: true as const } : {}),
handlerDepth: input.handlerDepth,
},
});
if (local.length === 0) return false;
const fetched = await this.stores.eventSubscription.getForTokens(
userId,
watched,
local,
{ keep: dispatchKeeps(options.forwarded) },
);
// A subscription naming no app is the desktop's view of the mailbox.
const rows = inAccountMailbox(context.type)
? fetched
: fetched.filter((row) => !notifMatchSpansEveryApp(row.match));
if (rows.length === 0) return;
if (rows.length === 0) return true;
await this.#route(subject, context, rows, userId, (matched) =>
this.#notifStillAuthorized(matched, context),
);
return true;
}
/**
@@ -6613,8 +6684,10 @@ export class EventsService extends PuterService {
*/
async invokeForSchedule(
invocation: WorkerInvocation,
options: Pick<WorkerRunOptions, 'stampEvent'> = {},
options: Pick<WorkerRunOptions, 'stampEvent' | 'beforeSend'> = {},
): Promise<ScheduleInvokeResult> {
if (options.beforeSend && !(await options.beforeSend()))
return { outcome: 'deferred' };
if (await this.#tooDeep(invocation)) return { outcome: 'too-deep' };
if (await this.#suspendIfHandlerGone(invocation))
return { outcome: 'deferred' };
@@ -6855,7 +6928,12 @@ export class EventsService extends PuterService {
delivery: AddressedDelivery,
): void {
if (this.#ended.has(row.subId) || this.#parkedUserRow(row)) return;
this.#coalesce().push(coalesceKey(row.subId, subject), delivery);
const event = delivery.envelope.event;
const key =
event.op === 'dismiss'
? `${subject}:dismiss:${event.uid}`
: subject;
this.#coalesce().push(coalesceKey(row.subId, key), delivery);
}
async #flush(delivery: AddressedDelivery): Promise<void> {
@@ -217,6 +217,97 @@ describe('the addressed peer queue', () => {
).toEqual(['b']);
});
it.each([false, true])(
'waits for an active send and ships its tail after failure %s',
async (failFirst) => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
let releaseTail!: () => void;
const tailHeld = new Promise<void>((resolve) => {
releaseTail = resolve;
});
const sent: ForwardItem[][] = [];
const { queue } = makeQueue({
maxItems: 1,
flushMs: 60_000,
send: async (_peerId, items) => {
sent.push(items);
if (sent.length === 1) {
await held;
if (failFirst) throw new Error('peer unavailable');
} else {
await tailHeld;
}
},
});
queue.push('east', delivery('first'));
await vi.waitFor(() => expect(sent).toHaveLength(1));
queue.push('east', delivery('tail'));
let finished = false;
const draining = queue.flushAll().then(() => {
finished = true;
});
try {
await new Promise((resolve) => setTimeout(resolve, 20));
expect(finished).toBe(false);
release();
await vi.waitFor(() => expect(sent).toHaveLength(2));
expect(finished).toBe(false);
} finally {
release();
releaseTail();
await draining;
queue.stop();
}
expect(sent).toEqual([[delivery('first')], [delivery('tail')]]);
},
);
it('releases the peer after a send throws synchronously', async () => {
const send = vi.fn(() => {
throw new Error('send failed');
});
const { queue } = makeQueue({ maxItems: 1, send });
queue.push('east', delivery('first'));
await queue.flushAll();
queue.push('east', delivery('second'));
await queue.flushAll();
expect(send).toHaveBeenCalledTimes(2);
});
it('bounds a final drain while a send is hung', async () => {
vi.useFakeTimers();
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const send = vi.fn(async () => {
await held;
});
const { queue } = makeQueue({ maxItems: 1, send });
try {
queue.push('east', delivery('first'));
queue.push('east', delivery('tail'));
let finished = false;
const draining = queue.flushAll().then(() => {
finished = true;
});
await vi.advanceTimersByTimeAsync(4_999);
expect(finished).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await draining;
queue.stop();
release();
await vi.advanceTimersByTimeAsync(100);
expect(send).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
} finally {
release();
vi.useRealTimers();
}
});
it('sends nothing more once it is stopped', async () => {
const { queue, sent } = makeQueue();
queue.stop();
+40 -10
View File
@@ -18,6 +18,8 @@
*/
import type { EventKey, KvOp } from '../../clients/event/types.js';
import { settleWithin } from '../../util/concurrency.js';
import { FINAL_FLUSH_TIMEOUT_MS } from '../../util/gracefulShutdown.js';
import type { DeliverableEvent } from './registry.js';
/**
@@ -86,7 +88,7 @@ export interface ForwardWatch {
/** One committed change, replayed against another region's session rows. */
export interface ForwardEvent {
kind: 'event';
family: 'fs' | 'kv';
family: 'fs' | 'kv' | 'notif';
ownerUserId: number;
/** Who caused it, so `self` is right on the far side. */
actingUserId?: number;
@@ -108,6 +110,17 @@ export interface ForwardEvent {
ancestors: Array<{ uid: string; path: string }>;
};
};
notif?: {
userUuid: string;
uid: string;
type: string;
audience: string;
appUid: string | null;
value: Record<string, unknown>;
dismissed?: true;
replaced?: true;
handlerDepth?: number;
};
kv?: {
userUuid: string;
appUid: string;
@@ -204,7 +217,7 @@ interface PeerQueue {
items: ForwardItem[];
bytes: number;
timer: ReturnType<typeof setTimeout> | null;
flushing: boolean;
flushing: Promise<void> | null;
}
export class PeerForwardQueue {
@@ -272,19 +285,27 @@ export class PeerForwardQueue {
}
/** Ship whatever is held for one peer. */
async flush(peerId: string): Promise<void> {
flush(peerId: string): Promise<void> {
const queue = this.#peers.get(peerId);
if (!queue || queue.flushing || queue.items.length === 0) return;
if (!queue) return Promise.resolve();
if (queue.flushing) return queue.flushing;
if (queue.items.length === 0) return Promise.resolve();
if (queue.timer) {
clearTimeout(queue.timer);
queue.timer = null;
}
queue.flushing = true;
queue.flushing = this.#send(peerId, queue);
return queue.flushing;
}
async #send(peerId: string, queue: PeerQueue): Promise<void> {
const batch = queue.items;
queue.items = [];
queue.bytes = 0;
try {
// Register the in-flight promise before a send can throw synchronously.
await Promise.resolve();
await this.#options.send(peerId, batch);
} catch (err) {
// A lost batch is a lost at-most-once delivery, and a `single`
@@ -292,7 +313,7 @@ export class PeerForwardQueue {
// hand the client an event whose moment has passed.
console.warn(`[events] forward to ${peerId} failed`, err);
} finally {
queue.flushing = false;
queue.flushing = null;
// Anything that arrived mid-flush gets the next window.
if (queue.items.length > 0 && !queue.timer && !this.#stopped) {
queue.timer = setTimeout(() => {
@@ -305,7 +326,16 @@ export class PeerForwardQueue {
}
async flushAll(): Promise<void> {
await Promise.all([...this.#peers.keys()].map((id) => this.flush(id)));
const deadline = Date.now() + FINAL_FLUSH_TIMEOUT_MS;
const work = Promise.allSettled(
[...this.#peers.entries()].map(async ([id, queue]) => {
await queue.flushing;
if (Date.now() < deadline) await this.flush(id);
}),
);
if (!(await settleWithin(work, FINAL_FLUSH_TIMEOUT_MS))) {
console.warn('[events] final flush timed out');
}
}
stop(): void {
@@ -323,7 +353,7 @@ export class PeerForwardQueue {
items: [],
bytes: 0,
timer: null,
flushing: false,
flushing: null,
};
this.#peers.set(peerId, queue);
return queue;
@@ -349,7 +379,7 @@ const isGapMarker = (item: ForwardItem): boolean =>
*/
const shed = (queue: PeerQueue, count: number): ForwardItem[] => {
const dropped: ForwardItem[] = [];
for (let i = 0; i < queue.items.length && dropped.length < count;) {
for (let i = 0; i < queue.items.length && dropped.length < count; ) {
if (isGapMarker(queue.items[i])) {
i++;
continue;
@@ -372,7 +402,7 @@ const shed = (queue: PeerQueue, count: number): ForwardItem[] => {
const shedBytes = (queue: PeerQueue, maxBytesHeld: number): ForwardItem[] => {
const dropped: ForwardItem[] = [];
let remaining = queue.bytes;
for (let i = 0; i < queue.items.length && remaining > maxBytesHeld;) {
for (let i = 0; i < queue.items.length && remaining > maxBytesHeld; ) {
if (isGapMarker(queue.items[i])) {
i++;
continue;
+66 -1
View File
@@ -402,7 +402,7 @@ const makeRegion = (
fields: { code: 'subject_does_not_exist' },
}),
},
notification: { notify: vi.fn() },
notification: { notify: vi.fn(), deliverOverSocket: vi.fn() },
metering: {
bufferIncrementUsages: (
actor: Actor,
@@ -2401,3 +2401,68 @@ describe('a durable row created in another region', () => {
);
});
});
describe('notification dismissals across connected pages', () => {
const config = { events: { enabled: true, notificationsFoldIn: true } } as Partial<IConfig>;
const appUid = 'notification-app';
const input = (uid = 'one') => ({
userId, userUuid: `user-${userId}`, uid,
type: 'app.message', audience: 'app-user', appUid,
value: {}, createdAt: Date.now(), dismissed: true as const,
});
it('reaches local and remote session pages without content or a forwarding loop', async () => {
const west = makeRegion('west', ['east'], config);
const east = makeRegion('east', ['west'], config);
for (const [region, socket] of [[west, 'local'], [east, 'remote']] as const) {
await region.events.subscribe(actorFor(appUid), socket, { subject: 'notif:app-user' });
}
await posted(east);
await quiet(100);
await west.events.dispatchNotif({ ...input(), value: { title: 'private' } });
await arrived(west);
await arrived(east);
for (const region of [west, east]) {
expect(region.sent).toHaveLength(1);
expect(region.sent[0].event).toMatchObject({ op: 'dismiss', uid: 'one', appUid });
expect(region.sent[0].event).not.toHaveProperty('notification');
}
expect(east.sent[0].event.id).toBe(west.sent[0].event.id);
const forwarded = west.posts.flatMap((p) => p.batch.items)
.find((item): item is ForwardEvent => item.kind === 'event');
expect(forwarded?.notif?.value).toEqual({});
expect(east.posts.flatMap((p) => p.batch.items).filter((i) => i.kind === 'event')).toEqual([]);
});
it('keeps separate dismissed ids in the same coalescing window', async () => {
const region = makeRegion('west', [], config);
await region.events.subscribe(actorFor(appUid), 'local', { subject: 'notif:app-user' });
await region.events.dispatchNotif(input('one'));
await region.events.dispatchNotif(input('two'));
await arrived(region, 2);
expect(region.sent.map((item) => 'uid' in item.event && item.event.uid)).toEqual(['one', 'two']);
});
it('does not deliver another app, account audience or another user to an app page', async () => {
const region = makeRegion('west', [], config);
await region.events.subscribe(actorFor(appUid), 'local', { subject: 'notif:app-user' });
await region.events.dispatchNotif({ ...input(), appUid: 'other-app' });
await region.events.dispatchNotif({ ...input(), audience: 'account' });
await region.events.dispatchNotif({ ...input(), userId: userId + 1, userUuid: 'other-user' });
await quiet(350);
expect(region.sent).toEqual([]);
});
it('also forwards postings through the same session path', async () => {
const west = makeRegion('west', ['east'], config);
const east = makeRegion('east', ['west'], config);
await east.events.subscribe(actorFor(appUid), 'remote', { subject: 'notif:app-user' });
await posted(east);
await quiet(100);
const { dismissed, ...postedInput } = input();
await west.events.dispatchNotif({ ...postedInput, value: { title: 'Hello' } });
await arrived(east);
expect(east.sent[0].event).toMatchObject({ op: 'post', uid: 'one', notification: { title: 'Hello' } });
});
});
+14 -12
View File
@@ -93,24 +93,25 @@ export interface ProjectedKvEvent extends ProjectedEventBase {
/**
* What a notification subscriber receives.
*
* `id` is the row's own uid rather than a per-dispatch uuid: a client that
* fetched a missed notification and then received the live copy has to be able
* to tell they are the same thing, and the mailbox uid is the only identity
* both halves share. `uid` carries it under the name the mailbox verbs
* (`mark-ack`, `mark-shown`) take.
* For postings, `id` is the row's own uid: a client that fetched a missed
* notification and then received the live copy has to be able to tell they are
* the same thing, and the mailbox uid is the only identity both halves share.
* `uid` carries it under the name the mailbox verbs (`mark-ack`, `mark-shown`)
* take.
*
* `notification` is the payload the caller sent, which is exactly what the
* socket adapter puts on `notif.message` — a subscriber reading this channel
* sees no less than the desktop does, and no internal row either.
* sees no less than the desktop does, and no internal row either. Dismissals
* use a fresh dispatch id and omit the payload.
*/
export interface ProjectedNotifEvent extends ProjectedEventBase {
op: 'post';
op: 'post' | 'dismiss';
uid: string;
type: string;
audience: NotificationAudience;
/** App the notification is about; `null` for platform notifications. */
appUid: string | null;
notification: Record<string, unknown>;
notification?: Record<string, unknown>;
/**
* Present when the notification replaced an earlier one under the same uid.
* `ts` is then the replacement time, the same on live and fetched copies,
@@ -200,6 +201,7 @@ export interface KvEventContext extends EventContextBase {
* recipient's uuid because the anchor token is the mailbox.
*/
export interface NotifEventContext extends EventContextBase {
dismissed?: true;
userId: number;
userUuid: string;
uid: string;
@@ -346,14 +348,14 @@ const notifMatchTarget = (event: NotifEventContext): string =>
const notifMatchScope = (_anchorPath: string, slice: string): string => slice;
const notifProject = (delivery: NotifDeliveryContext): ProjectedNotifEvent => ({
id: delivery.uid,
id: delivery.dismissed ? delivery.id : delivery.uid,
subject: `notif:${delivery.appUid ?? delivery.userUuid}:${delivery.audience}`,
op: 'post',
op: delivery.dismissed ? 'dismiss' : 'post',
uid: delivery.uid,
type: delivery.type,
audience: delivery.audience,
appUid: delivery.appUid,
notification: delivery.notification,
...(delivery.dismissed ? {} : { notification: delivery.notification }),
...(delivery.replaced ? { replaced: true as const } : {}),
self: delivery.self,
ts: delivery.ts,
@@ -464,7 +466,7 @@ export const PUBLIC_SUBJECTS = [
{
family: 'notif',
subject: 'notif:*',
internal: ['notif.created'],
internal: ['notif.created', 'notif.acknowledged'],
tokens: notifTokens,
matchOn: notifMatchTarget,
matchSeparator: NOTIF_MATCH_SEPARATOR,
@@ -83,6 +83,7 @@ const recording = () => {
createFor: vi.fn(),
list: vi.fn(),
get: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
receivePeerChanges: vi.fn(),
prepareSendAt: vi.fn(),
+1 -2
View File
@@ -48,8 +48,7 @@ export type SubjectFamily = 'fs' | 'kv' | 'notif';
export type FsOp = 'add' | 'write' | 'move' | 'remove' | 'meta';
/** A notification is only ever posted; the mailbox verbs are their own surface. */
export type NotifOp = 'post';
export type NotifOp = 'post' | 'dismiss';
export type SubjectOp = FsOp | KvOp | NotifOp;
@@ -218,6 +218,7 @@ export class EventsWorkerInvoker implements WorkerInvokerSeam {
{
lane: options.lane,
stampEvent: options.stampEvent,
beforeSend: options.beforeSend,
},
);
if (result.deployBusy) return { outcome: 'deploy-busy' };
@@ -0,0 +1,99 @@
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import type { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
const { disposals } = vi.hoisted(() => ({
disposals: [] as Array<ReturnType<typeof vi.fn>>,
}));
vi.mock('miniflare', () => ({
Miniflare: class {
dispose = vi.fn(async () => {});
constructor() {
disposals.push(this.dispose);
}
},
}));
describe('LocalWorkerService shutdown disposal', () => {
let server: PuterServer;
beforeAll(async () => {
server = await setupTestServer({
api_base_url: 'http://api.puter.localhost',
} as never);
});
afterAll(async () => {
await server.shutdown();
});
afterEach(() => {
vi.useRealTimers();
disposals.length = 0;
});
it('awaits every disposal even if one fails', async () => {
const service = server.services.localworkerservice;
await service.cfDeployLocal('first', 'auth', 'export default {}');
await service.cfDeployLocal('second', 'auth', 'export default {}');
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
disposals[0].mockRejectedValueOnce(new Error('dispose failed'));
disposals[1].mockImplementationOnce(async () => {
await held;
});
let finished = false;
const draining = Promise.resolve(service.onServerShutdown()).then(
() => {
finished = true;
},
);
try {
await new Promise((resolve) => setTimeout(resolve, 20));
expect(
disposals.every((dispose) => dispose.mock.calls.length === 1),
).toBe(true);
expect(finished).toBe(false);
} finally {
release();
await draining;
}
});
it('bounds disposal of a hung worker to five seconds', async () => {
const service = server.services.localworkerservice;
await service.cfDeployLocal('hung', 'auth', 'export default {}');
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
disposals[0].mockImplementationOnce(async () => {
await held;
});
vi.useFakeTimers();
try {
let finished = false;
const draining = Promise.resolve(service.onServerShutdown()).then(
() => {
finished = true;
},
);
await vi.advanceTimersByTimeAsync(4_999);
expect(finished).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await draining;
expect(vi.getTimerCount()).toBe(0);
} finally {
release();
vi.useRealTimers();
}
});
});
@@ -10,6 +10,8 @@ import {
import { puterStores } from '../../stores';
import type { SubdomainRow } from '../../stores/subdomain/SubdomainStore';
import { LayerInstances } from '../../types';
import { settleWithin } from '../../util/concurrency.js';
import { FINAL_FLUSH_TIMEOUT_MS } from '../../util/gracefulShutdown.js';
import { PuterService } from '../types';
const MAX_SOURCE_SIZE = 10 * 1024 * 1024; // 10 MB
@@ -240,13 +242,16 @@ export class LocalWorkerService extends PuterService {
idleSweepTimer.unref?.();
}
override onServerShutdown(): void {
override async onServerShutdown(): Promise<void> {
if (idleSweepTimer) {
clearInterval(idleSweepTimer);
idleSweepTimer = null;
}
for (const name of [...activeWorkers.keys()]) {
void this.#disposeWorker(name);
const work = Promise.allSettled(
[...activeWorkers.keys()].map((name) => this.#disposeWorker(name)),
);
if (!(await settleWithin(work, FINAL_FLUSH_TIMEOUT_MS))) {
console.warn('[localworker] final flush timed out');
}
}
async reconstructDeployArgs(workerName: string, row: SubdomainRow) {
@@ -0,0 +1,39 @@
import { expect, it } from 'vitest';
import { EventClient } from '../../clients/event/EventClient.js';
import type { EventMap } from '../../clients/event/types.js';
import { NotificationService } from './NotificationService.js';
it.each([undefined, 0, 1_700_000_000_000])(
'emits replacement metadata for replacedAt %s',
async (replacedAt) => {
const event = new EventClient({} as never);
const written: EventMap['notif.written'][] = [];
event.on('notif.written', (_key, value) => {
written.push(value);
});
const service = new NotificationService(
{} as never,
{ event } as never,
{} as never,
);
await service.emitWritten(
{
userId: 1,
uid: 'notification-1',
type: 'app.message',
audience: 'app-user',
appUid: 'app-1',
value: { title: 'A message' },
updated: replacedAt !== undefined,
replacedAt,
},
{ sendId: 'send-1' },
);
expect(written).toHaveLength(1);
if (replacedAt === undefined)
expect(written[0]).not.toHaveProperty('replaced');
else expect(written[0].replaced).toBe(true);
},
);
@@ -354,6 +354,7 @@ describe('NotificationService.notifyUpdate', () => {
appUid,
value: { title: 'two messages', type: 'app.message' },
updated: true,
replaced: true,
});
expect(desktop.seen).toEqual([]);
expect(
@@ -310,6 +310,9 @@ export class NotificationService extends PuterService {
...scope,
value: structuredClone(payload),
...(updated ? { updated: true as const } : {}),
...(written.replacedAt !== undefined
? { replaced: true as const }
: {}),
sendId: opts.sendId,
...(opts.replayable ? { replayable: true as const } : {}),
},
@@ -172,13 +172,23 @@ describe('parseAppMessage', () => {
rejects({ title: 't', sensitive: 'yes' }, 'sensitive');
});
it('refuses high priority to apps', () => {
expect(() => parseAppMessage({ title: 't', priority: 'high' })).toThrow(
"'high' is not available to apps",
);
rejects({ title: 't', priority: 'urgent' }, 'priority');
it('accepts normal and high priority, defaulting to normal', () => {
expect(parseAppMessage({ title: 't' }).priority).toBe('normal');
expect(
parseAppMessage({ title: 't', priority: 'normal' }).priority,
).toBe('normal');
expect(
parseAppMessage({ title: 't', priority: 'high' }).priority,
).toBe('high');
});
it.each(['urgent', 'low', '', null, 1, true])(
'rejects priority %s',
(priority) => {
rejects({ title: 't', priority }, 'priority');
},
);
it('bounds ttl to whole seconds within a day', () => {
expect(parseAppMessage({ title: 't', ttl: 1 }).ttl).toBe(1);
rejects({ title: 't', ttl: 0 }, 'ttl');
@@ -39,7 +39,7 @@ export const APP_MESSAGE_RATE_LIMITS = {
perDay: 1000,
} as const;
export type AppMessagePriority = 'normal';
export type AppMessagePriority = 'normal' | 'high';
/** An `app.message` value as stored, after normalization. */
export type AppMessage = {
@@ -322,13 +322,11 @@ export const parseAppMessage = (input: Record<string, unknown>): AppMessage => {
message.sensitive = input.sensitive;
}
if (input.priority !== undefined && input.priority !== 'normal') {
throw invalid(
'priority',
input.priority === 'high'
? "'high' is not available to apps; use 'normal'"
: "must be 'normal'",
);
if (input.priority !== undefined) {
if (input.priority !== 'normal' && input.priority !== 'high') {
throw invalid('priority', "must be 'normal' or 'high'");
}
message.priority = input.priority;
}
return message;
@@ -17,6 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { HttpError } from '../../core/http/HttpError.js';
/** Who the recipient is in relation to the notification. */
export type NotificationAudience = 'account' | 'developer' | 'app-user';
@@ -153,6 +155,22 @@ export type NotificationTypeName = (typeof NOTIFICATION_TYPES)[number]['type'];
/** Serialized `value` may not exceed this many bytes. */
export const NOTIFICATION_VALUE_MAX_BYTES = 16 * 1024;
export const assertNotificationValueFits = (
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 BY_NAME = new Map<string, NotificationType>(
NOTIFICATION_TYPES.map((entry) => [entry.type, entry]),
);
@@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Actor } from '../../core/actor.js';
import type { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import type { ResolvedShare } from './ShareService.js';
describe('ShareNotificationService shutdown drain', () => {
let server: PuterServer;
beforeEach(async () => {
server = await setupTestServer({
email: { from: '[email protected]', host: '127.0.0.1', port: 1 },
share_notify_limits: { emailBatchSeconds: 0.01 },
} as never);
});
afterEach(async () => {
vi.useRealTimers();
await server.services.shareNotification.onServerPrepareShutdown();
await server.services.shareNotification.onServerShutdown();
await server.shutdown();
vi.restoreAllMocks();
});
const queueInvite = async (email: string, issuerId = 7) => {
await server.services.shareNotification.notifyShared(
{
user: { id: issuerId, username: 'sender' },
effectiveApp: null,
} as Actor,
[
{
pending: true,
isNew: true,
recipientEmail: email,
name: 'shared.txt',
} as ResolvedShare,
],
);
};
it.each([false, true])(
'awaits an active timer send and its failure requeue %s',
async (failSend) => {
const email = `active-${failSend}@puter.local`;
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const send = vi
.spyOn(server.clients.email, 'sendRaw')
.mockImplementation(async () => {
await held;
if (failSend) throw new Error('transport unavailable');
return null;
});
await queueInvite(email);
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1));
const list = async () =>
server.stores.kv.list({
as: 'keys',
pattern: `share:digest:invite:${email}:`,
});
expect((await list()).res).toEqual([]);
let finished = false;
const draining = server.services.shareNotification
.onServerPrepareShutdown()
.then(() => {
finished = true;
});
try {
await new Promise((resolve) => setTimeout(resolve, 20));
expect(finished).toBe(false);
} finally {
release();
await draining;
}
expect((await list()).res).toHaveLength(failSend ? 1 : 0);
},
);
it('waits for an active send before draining entries queued meanwhile', async () => {
let releaseFirst!: () => void;
let releaseTail!: () => void;
const first = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const tail = new Promise<void>((resolve) => {
releaseTail = resolve;
});
const send = vi
.spyOn(server.clients.email, 'sendRaw')
.mockImplementationOnce(async () => {
await first;
return null;
})
.mockImplementationOnce(async () => {
await tail;
return null;
});
await queueInvite('[email protected]');
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1));
(
server.services.shareNotification.config.share_notify_limits as {
emailBatchSeconds: number;
}
).emailBatchSeconds = 600;
await queueInvite('[email protected]', 8);
let finished = false;
const draining = server.services.shareNotification
.onServerPrepareShutdown()
.then(() => {
finished = true;
});
try {
releaseFirst();
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(2));
expect(finished).toBe(false);
} finally {
releaseFirst();
releaseTail();
await draining;
}
expect(send).toHaveBeenCalledTimes(2);
});
it.each(['onServerPrepareShutdown', 'onServerShutdown'] as const)(
'bounds %s while an active send is hung',
async (hook) => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const send = vi
.spyOn(server.clients.email, 'sendRaw')
.mockImplementationOnce(async () => {
await held;
return null;
});
await queueInvite(`hung-${hook}@puter.local`);
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1));
vi.useFakeTimers();
try {
let finished = false;
const draining = server.services.shareNotification[hook]().then(
() => {
finished = true;
},
);
await vi.advanceTimersByTimeAsync(4_999);
expect(finished).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await draining;
} finally {
release();
vi.useRealTimers();
}
},
);
it('drains a digest queued after prepare at shutdown', async () => {
await server.services.shareNotification.onServerPrepareShutdown();
const send = vi
.spyOn(server.clients.email, 'sendRaw')
.mockResolvedValue(null);
(
server.services.shareNotification.config.share_notify_limits as {
emailBatchSeconds: number;
}
).emailBatchSeconds = 600;
await queueInvite('[email protected]');
await server.services.shareNotification.onServerShutdown();
expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.calls[0][0].to).toBe('[email protected]');
await new Promise((resolve) => setTimeout(resolve, 30));
expect(send).toHaveBeenCalledTimes(1);
});
});
@@ -20,7 +20,11 @@
import { createHash, randomUUID } from 'node:crypto';
import type { Actor } from '../../core/actor';
import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
import {
runWithConcurrencyLimitSettled,
settleWithin,
} from '../../util/concurrency.js';
import { FINAL_FLUSH_TIMEOUT_MS } from '../../util/gracefulShutdown.js';
import { PuterService } from '../types';
import {
digestItemPaths,
@@ -178,6 +182,7 @@ export class ShareNotificationService extends PuterService {
* queued sends live in KV, where any node's flush can pick them up.
*/
#digestTimers = new Map<string, ReturnType<typeof setTimeout>>();
#digestFlushes = new Map<string, Promise<void>>();
/** The recovery sweep; see `onServerStart`. */
#digestSweep: ReturnType<typeof setInterval> | null = null;
@@ -199,12 +204,46 @@ export class ShareNotificationService extends PuterService {
/** Send what's still waiting while the transport is alive to send it. */
override async onServerPrepareShutdown(): Promise<void> {
await this.#drainDigests();
}
override async onServerShutdown(): Promise<void> {
await this.#drainDigests();
}
async #drainDigests(): Promise<void> {
if (this.#digestSweep) clearInterval(this.#digestSweep);
this.#digestSweep = null;
const keys = [...this.#digestTimers.keys()];
const keys = new Set([
...this.#digestTimers.keys(),
...this.#digestFlushes.keys(),
]);
for (const [, timer] of this.#digestTimers) clearTimeout(timer);
this.#digestTimers.clear();
await Promise.all(keys.map((key) => this.#flushDigest(key)));
const active = [...this.#digestFlushes.values()];
const deadline = Date.now() + FINAL_FLUSH_TIMEOUT_MS;
const drain = async () => {
try {
await Promise.allSettled(active);
if (Date.now() >= deadline) return;
for (const [key, timer] of this.#digestTimers) {
keys.add(key);
clearTimeout(timer);
}
this.#digestTimers.clear();
await Promise.allSettled(
[...keys].map((key) => this.#flushDigest(key)),
);
} catch (err) {
console.warn(
'[share-notify] shutdown digest flush failed:',
err,
);
}
};
if (!(await settleWithin(drain(), FINAL_FLUSH_TIMEOUT_MS))) {
console.warn('[share-notify] final flush timed out');
}
}
/** The recovery sweep, for tests that can't wait out an interval. */
@@ -878,25 +917,37 @@ export class ShareNotificationService extends PuterService {
* everything queued), and across regions the KV `take` is the real claim,
* handing each entry to exactly one flusher.
*/
async #flushDigest(key: string): Promise<void> {
await withRedisLock(
this.clients.redis,
`share:digest:lock:${key}`,
() => this.#flushDigestLocked(key),
{
ttlMs: DIGEST_LOCK_SECONDS * 1000,
// No lock beats no mail; `take` still prevents double sends.
onUnavailable: 'run',
onBusy: () => {
console.log(
'[share-notify] another flush holds the lock:',
{
key,
},
);
#flushDigest(key: string): Promise<void> {
const active = this.#digestFlushes.get(key);
if (active) return active;
const flushing = this.#sendDigest(key);
this.#digestFlushes.set(key, flushing);
return flushing;
}
async #sendDigest(key: string): Promise<void> {
try {
await withRedisLock(
this.clients.redis,
`share:digest:lock:${key}`,
() => this.#flushDigestLocked(key),
{
ttlMs: DIGEST_LOCK_SECONDS * 1000,
// No lock beats no mail; `take` still prevents double sends.
onUnavailable: 'run',
onBusy: () => {
console.log(
'[share-notify] another flush holds the lock:',
{ key },
);
},
},
},
);
);
} catch (err) {
console.warn('[share-notify] digest flush failed:', err);
} finally {
this.#digestFlushes.delete(key);
}
}
async #flushDigestLocked(key: string): Promise<void> {
@@ -122,6 +122,59 @@ describe('SignupService.runSignupGate', () => {
}
});
it('an OIDC-only policy refuses form and temp signups before the validate hook, and lets OIDC and team provisioning through', async () => {
const oidcOnly = (_k: string, data: unknown) => {
Object.assign(data as object, { oidcOnly: true });
};
server.clients.event.on('puter.signup.policy', oidcOnly);
try {
const gate = (input: {
isTemp: boolean;
source?: 'oidc' | 'team_provision';
}) =>
capture('puter.signup.validate', () =>
server.services.signup.runSignupGate({
req,
data: {},
email: `policy-${slug()}@example.com`,
bonusCode: null,
...input,
}),
);
const form = await gate({ isTemp: false });
const temp = await gate({ isTemp: true });
const oidc = await gate({ isTemp: false, source: 'oidc' });
const team = await gate({
isTemp: false,
source: 'team_provision',
});
expect(form.result).toMatchObject({
verdict: 'blocked',
code: 'signup_oidc_only',
});
expect(temp.result).toEqual({
verdict: 'no_temp_user',
message: null,
code: null,
});
expect(form.seen).toHaveLength(0);
expect(temp.seen).toHaveLength(0);
expect(oidc.result.verdict).toBe('allowed');
expect(team.result.verdict).toBe('allowed');
expect(await server.services.signup.signupPolicy()).toEqual({
oidcOnly: true,
message: null,
});
} finally {
server.clients.event.off('puter.signup.policy', oidcOnly);
}
expect(await server.services.signup.signupPolicy()).toEqual({
oidcOnly: false,
message: null,
});
});
it('refuses an unknown bonus code before the validate hook runs', async () => {
const { result, seen } = await capture('puter.signup.validate', () =>
server.services.signup.runSignupGate({
@@ -89,6 +89,9 @@ export interface SignupInput {
export const signupClientIp = (req: Request): string | null =>
req.ip || req.socket?.remoteAddress || null;
const OIDC_ONLY_MESSAGE =
'New accounts can only be created with a sign-in provider right now.';
/**
* Account creation for every signup route (form, temp, OIDC) and email
* confirmation for every confirmation route. Callers parse requests and set
@@ -147,6 +150,30 @@ export class SignupService extends PuterService {
}
}
/**
* The signup methods extensions allow right now; a failing hook restricts
* nothing.
*/
async signupPolicy(): Promise<{
oidcOnly: boolean;
message: string | null;
}> {
const policy = { oidcOnly: false, message: null as string | null };
try {
await this.clients.event?.emitAndWait(
'puter.signup.policy',
policy,
{},
);
} catch (e) {
console.warn('[signup] policy hook failed:', e);
}
return {
oidcOnly: policy.oidcOnly === true,
message: typeof policy.message === 'string' ? policy.message : null,
};
}
/**
* The extension-owned verdict on a signup: a dead bonus code is refused
* first (before `puter.signup.validate` records the attempt), then the
@@ -157,6 +184,23 @@ export class SignupService extends PuterService {
const clientIp = signupClientIp(req);
const fingerprint = input.fingerprint ?? null;
// Only form and temp signups carry no source; OIDC and team
// provisioning are never restricted by the policy.
if (!input.source) {
const policy = await this.signupPolicy();
if (policy.oidcOnly) {
// A refused temp signup sends the GUI to its signup window,
// which then offers only the providers.
return isTemp
? { verdict: 'no_temp_user', message: null, code: null }
: {
verdict: 'blocked',
message: policy.message ?? OIDC_ONLY_MESSAGE,
code: 'signup_oidc_only',
};
}
}
if (
bonusCode &&
!(
@@ -466,7 +466,10 @@ export class DurableSubscriptionStore extends PuterStore {
async suspend(
rows: readonly DurableSubscription[],
reason: SuspendedReason,
opts: { override?: readonly SuspendedReason[] } = {},
opts: {
override?: readonly SuspendedReason[];
guard?: SqlGuard;
} = {},
): Promise<{ suspended: DurableSubscription[]; bumps: GenerationBump[] }> {
if (rows.length === 0) return { suspended: [], bumps: [] };
@@ -488,8 +491,17 @@ export class DurableSubscriptionStore extends PuterStore {
const written = await this.clients.db.write(
`UPDATE \`${TABLE}\` SET \`suspended_at\` = ?, ` +
'`suspended_reason` = ? ' +
`WHERE \`sub_id\` = ? AND ${condition}`,
[at, reason, row.subId, ...override],
`WHERE \`sub_id\` = ? AND ${condition}` +
(opts.guard
? ` AND EXISTS (SELECT 1 FROM \`${opts.guard.table}\` WHERE ${opts.guard.where})`
: ''),
[
at,
reason,
row.subId,
...override,
...(opts.guard?.values ?? []),
],
);
if (written.anyRowsAffected)
suspended.push({
@@ -959,6 +971,20 @@ export class DurableSubscriptionStore extends PuterStore {
return this.#reap(await this.#listForApp(appUid, batchSize));
}
scheduleSubscriptionContextEntry(
scheduleUid: string,
context: string | null,
guard: SqlGuard,
): BatchEntry {
assertDurableContext(context);
return {
statement:
`UPDATE \`${TABLE}\` SET \`context\` = ? WHERE \`token\` = ? ` +
`AND EXISTS (SELECT 1 FROM \`${guard.table}\` WHERE ${guard.where})`,
values: [context, scheduleToken(scheduleUid), ...guard.values],
};
}
/**
* Delete the row a schedule owns, whether or not the schedule ever recorded
* its id: a create that crashed between the two inserts leaves one it
@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from 'vitest';
import { EventClient } from '../../clients/event/EventClient.js';
import type { EventMap } from '../../clients/event/types.js';
import { NotificationStore } from './NotificationStore.js';
const setup = (row: Record<string, unknown> | null, changed = true) => {
const event = new EventClient({} as never);
const emitted: EventMap['notif.acknowledged'][] = [];
event.on('notif.acknowledged', (_key, data) => { emitted.push(data); });
const db = {
write: vi.fn(async () => ({ anyRowsAffected: changed })),
pread: vi.fn(async () => row ? [row] : []),
read: vi.fn(async () => []),
};
const store = new NotificationStore({}, { event, db } as never, {});
return { store, emitted, db };
};
const appRow = {
uid: 'one', user_id: 7, type: 'app.message', app_uid: 'app-one',
audience: 'app-user', value: { title: 'private', data: 'private' },
};
describe('acknowledgement emission', () => {
it('announces a committed app message dismissal without content', async () => {
const { store, emitted, db } = setup(appRow);
await expect(store.markAcknowledged('one', 7)).resolves.toBe(true);
expect(db.write.mock.calls[0][0]).toContain('`acknowledged` IS NULL');
expect(db.pread).toHaveBeenCalledWith(expect.stringContaining('`user_id` = ?'), ['one', 7]);
expect(emitted).toEqual([{ userId: 7, uid: 'one', type: 'app.message', audience: 'app-user', appUid: 'app-one' }]);
});
it.each([
{ ...appRow, type: 'share.received' },
{ ...appRow, app_uid: null },
null,
])('does not announce a row outside an app message scope: %s', async (row) => {
const { store, emitted } = setup(row);
await store.markAcknowledged('one', 7);
expect(emitted).toEqual([]);
});
it('does not announce or read back an unchanged row', async () => {
const { store, emitted, db } = setup(appRow, false);
await expect(store.markAcknowledged('one', 7)).resolves.toBe(false);
expect(db.pread).not.toHaveBeenCalled();
expect(emitted).toEqual([]);
});
it('preserves a committed acknowledgement if the announcement read fails', async () => {
const { store, db, emitted } = setup(appRow);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
db.pread.mockRejectedValueOnce(new Error('unavailable'));
try {
await expect(store.markAcknowledged('one', 7)).resolves.toBe(true);
expect(emitted).toEqual([]);
} finally {
warn.mockRestore();
}
});
it('does not propagate markShown', async () => {
const { store, emitted } = setup(appRow);
await store.markShown('one', 7);
expect(emitted).toEqual([]);
});
});
describe('displayed notification state query', () => {
it('reads all named ids in one scoped query', async () => {
const { store, db } = setup(appRow);
await store.listByUserId(7, {
limit: 50, uids: ['one', 'two'],
scope: { appUid: 'app-one', audiences: ['app-user'] },
});
expect(db.pread).not.toHaveBeenCalled();
expect(db.read).toHaveBeenCalledExactlyOnceWith(
expect.stringContaining('`uid` IN (?, ?)'),
[7, 'one', 'two', 'app-user', 'app-one', 50],
);
expect(db.read.mock.calls[0][0]).toContain('`user_id` = ?');
expect(db.read.mock.calls[0][0]).toContain('`app_uid` = ?');
});
it('does not query an empty id set', async () => {
const { store, db } = setup(appRow);
expect(await store.listByUserId(7, { uids: [] })).toEqual([]);
expect(db.read).not.toHaveBeenCalled();
});
});
@@ -156,11 +156,12 @@ export class NotificationStore extends PuterStore {
* @param {number} userId
* @param {{
* limit?: number;
* uids?: readonly string[];
* onlyUnacknowledged?: boolean;
* filter?: string;
* scope?: {
* audiences: readonly string[];
* appUid: string | null;
* appUid?: string | null;
* } | null;
* excludeTypes?: readonly string[];
* }} [opts]
@@ -169,6 +170,7 @@ export class NotificationStore extends PuterStore {
userId,
{
limit = 200,
uids,
onlyUnacknowledged = false,
filter = undefined,
scope = null,
@@ -178,6 +180,12 @@ export class NotificationStore extends PuterStore {
const where = ['`user_id` = ?'];
const params = [userId];
if (uids) {
if (uids.length === 0) return [];
where.push(`\`uid\` IN (${uids.map(() => '?').join(', ')})`);
params.push(...uids);
}
if (excludeTypes.length > 0) {
where.push(
`\`type\` NOT IN (${excludeTypes.map(() => '?').join(', ')})`,
@@ -573,6 +581,32 @@ export class NotificationStore extends PuterStore {
'UPDATE `notification` SET `acknowledged` = ? WHERE `uid` = ? AND `user_id` = ? AND `acknowledged` IS NULL',
[now, uid, userId],
);
if (result.anyRowsAffected) {
try {
const [row] = await this.clients.db.pread(
'SELECT `type`, `app_uid`, `audience` FROM `notification` WHERE `uid` = ? AND `user_id` = ? LIMIT 1',
[uid, userId],
);
if (row?.type === 'app.message' && row.app_uid) {
this.clients.event.emit(
'notif.acknowledged',
{
userId,
uid,
type: row.type,
audience: row.audience,
appUid: row.app_uid,
},
{},
);
}
} catch (error) {
console.warn(
'[notification] dismissal announcement failed',
error,
);
}
}
return result.anyRowsAffected;
}
+10 -4
View File
@@ -256,7 +256,12 @@ export interface IPreludeConfig {
* an RCS agent provisioned in the Prelude account to actually use RCS.
*/
preferredChannel?:
'sms' | 'rcs' | 'whatsapp' | 'viber' | 'zalo' | 'telegram';
| 'sms'
| 'rcs'
| 'whatsapp'
| 'viber'
| 'zalo'
| 'telegram';
}
/**
@@ -1375,7 +1380,9 @@ export interface WithLifecycle extends Object {
/**
* When the drain starts, before `onServerPrepareShutdown`, which waits for
* every connection (upgraded sockets included) to close or the drain to
* end.
* end. Hooks run concurrently, and the server keeps accepting connections
* until all of them settle, so a hook can hold the listener open (until
* traffic stops arriving, say) by returning a promise.
*/
onServerDrainStart?: (info?: ServerDrainInfo) => Promise<void> | void;
}
@@ -1386,8 +1393,7 @@ export interface ServerDrainInfo {
}
export interface WithCostsReporting extends WithLifecycle {
getReportedCosts?: () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getReportedCosts?: () => // eslint-disable-next-line @typescript-eslint/no-explicit-any
| Promise<Record<string, any>[]>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Record<string, any>[];
+63 -10
View File
@@ -3,23 +3,25 @@
*
* 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.
* 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.
* 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/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
awaitLater,
settleWithin,
runWithConcurrencyLimit,
runWithConcurrencyLimitSettled,
} from './concurrency.ts';
@@ -221,3 +223,54 @@ describe('awaitLater', () => {
expect(await awaitLater(Promise.resolve(42))).toBe(42);
});
});
describe('settleWithin', () => {
afterEach(() => vi.useRealTimers());
it('returns true and clears the timer when work settles in time', async () => {
vi.useFakeTimers();
const work = Promise.withResolvers<void>();
const waiting = settleWithin(work.promise, 5000);
expect(vi.getTimerCount()).toBe(1);
work.resolve();
await expect(waiting).resolves.toBe(true);
expect(vi.getTimerCount()).toBe(0);
});
it('returns false and clears the timer when time runs out', async () => {
vi.useFakeTimers();
const cleared = vi.spyOn(globalThis, 'clearTimeout');
try {
const waiting = settleWithin(new Promise(() => {}), 5000);
await vi.advanceTimersByTimeAsync(5000);
await expect(waiting).resolves.toBe(false);
expect(cleared).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
} finally {
cleared.mockRestore();
}
});
it.each([false, true])(
'handles a rejection after timeout %s',
async (afterTimeout) => {
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on('unhandledRejection', onUnhandled);
try {
const work = Promise.withResolvers<void>();
const waiting = settleWithin(
work.promise,
afterTimeout ? 0 : 5000,
);
if (afterTimeout) await expect(waiting).resolves.toBe(false);
work.reject(new Error('work failed'));
await expect(waiting).resolves.toBe(!afterTimeout);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(unhandled).toEqual([]);
} finally {
process.off('unhandledRejection', onUnhandled);
}
},
);
});
+29
View File
@@ -110,3 +110,32 @@ export function awaitLater<T>(promise: Promise<T>): Promise<T> {
})();
return promise;
}
/**
* Waits for `promise` for at most `ms`, without rejecting. Resolves `true` if
* it settled in time, `false` if the time ran out first.
*/
export async function settleWithin(
promise: Promise<unknown>,
ms: number,
): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined;
const settled = async () => {
try {
await promise;
} catch {
// Callers report work failures.
}
return true;
};
try {
return await Promise.race([
settled(),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), ms);
}),
]);
} finally {
clearTimeout(timer);
}
}
+46
View File
@@ -83,6 +83,52 @@ describe('createGracefulShutdown', () => {
expect(drainInfo).toEqual([{ drainMs: 90_000 }]);
});
it('counts time spent in prepareShutdown against the drain window', async () => {
const exit = vi.fn((code: number) => events.push(`exit:${code}`));
const target = makeTarget();
const trigger = createGracefulShutdown(
{
...target,
prepareShutdown: async (info) => {
await target.prepareShutdown(info);
await new Promise((resolve) => setTimeout(resolve, 15_000));
},
},
{ drainMs: 90_000, exit },
);
const done = trigger('SIGTERM');
await vi.advanceTimersByTimeAsync(89_999);
expect(events).toEqual(['prepare']);
await vi.advanceTimersByTimeAsync(1);
await done;
expect(events).toEqual(['prepare', 'shutdown', 'telemetry', 'exit:0']);
});
it('waits for prepareShutdown when it outlasts the drain window', async () => {
const exit = vi.fn((code: number) => events.push(`exit:${code}`));
const target = makeTarget();
const trigger = createGracefulShutdown(
{
...target,
prepareShutdown: async (info) => {
await target.prepareShutdown(info);
await new Promise((resolve) => setTimeout(resolve, 2_000));
},
},
{ drainMs: 1_000, exit },
);
const done = trigger('SIGTERM');
await vi.advanceTimersByTimeAsync(1_999);
expect(events).toEqual(['prepare']);
await vi.advanceTimersByTimeAsync(1);
await done;
expect(events).toEqual(['prepare', 'shutdown', 'telemetry', 'exit:0']);
});
it('SIGINT with a drain function returning 0 for SIGINT exits without waiting', async () => {
const exit = vi.fn((code: number) => events.push(`exit:${code}`));
const drainMs = (signal: string) => (signal === 'SIGTERM' ? 90_000 : 0);
+12 -6
View File
@@ -27,9 +27,9 @@ export interface ShutdownTarget {
export interface GracefulShutdownOptions {
/**
* How long to keep serving after new connections stop, before teardown. A
* function is called with the signal that triggered shutdown, so a
* deployment can drain on `SIGTERM` but exit `SIGINT` (Ctrl-C) instantly.
* How long to keep serving after the signal, before teardown. A function is
* called with the signal that triggered shutdown, so a deployment can drain
* on `SIGTERM` but exit `SIGINT` (Ctrl-C) instantly.
*/
drainMs: number | ((signal: string) => number);
/** Cap on the final telemetry flush. */
@@ -45,6 +45,8 @@ export interface GracefulShutdownOptions {
export const DEFAULT_TELEMETRY_SHUTDOWN_TIMEOUT_MS = 5_000;
export const DEFAULT_SHUTDOWN_HARD_LIMIT_MS = 25_000;
/** Cap on one component's final flush, so teardown fits the hard limit. */
export const FINAL_FLUSH_TIMEOUT_MS = 5_000;
/**
* The process's one shutdown sequence: stop accepting connections, drain, tear
@@ -79,14 +81,18 @@ export const createGracefulShutdown = (
exitOnce(1);
}, drain + hardLimitMs);
let exitCode = 0;
// The drain window starts at the signal, so time `prepareShutdown`
// spends holding the listener open counts against it.
const drained =
drain > 0
? new Promise<void>((resolve) => setTimeout(resolve, drain))
: Promise.resolve();
try {
await target.prepareShutdown({ drainMs: drain });
} catch (err) {
console.error('[shutdown] prepareShutdown failed', err);
}
if (drain > 0) {
await new Promise<void>((resolve) => setTimeout(resolve, drain));
}
await drained;
try {
await target.shutdown();
} catch (err) {
+28 -2
View File
@@ -5,7 +5,7 @@ let sidebar = require('./src/sidebar');
const redirects = require('./src/redirects');
const menuItems = require('./src/menu.js');
const examples = require('./src/examples');
const { encode } = require('html-entities');
const { encode, decode } = require('html-entities');
const { JSDOM } = require('jsdom');
const esbuild = require('esbuild');
const MiniSearch = require('minisearch');
@@ -45,11 +45,30 @@ const site = 'https://docs.puter.com';
let usedPlaygroundExamples = new Set();
let anyErrors = false;
// Heading ids already used on the page being parsed. Reset before each parse.
let usedHeadingSlugs = new Set();
marked.use({
hooks: {
preprocess (markdown) {
usedHeadingSlugs = new Set();
return markdown;
},
},
renderer: {
// Add a link to each subheading
heading (text, level) {
const slug = text.toLowerCase().replace(/[^\w]+/g, '-');
// `text` is rendered HTML, so drop tags and entities before slugging.
const baseSlug = decode(removeTags(text))
.toLowerCase()
.replace(/[^\w]+/g, '-')
.replace(/^-+|-+$/g, '') || 'section';
// A repeated heading gets a numbered id, so each anchor is unique.
let slug = baseSlug;
for ( let i = 1; usedHeadingSlugs.has(slug); i++ ) {
slug = `${baseSlug}-${i}`;
}
usedHeadingSlugs.add(slug);
return `
<h${level} class="anchored-heading" id="${slug}">
@@ -58,6 +77,13 @@ marked.use({
</h${level}>`;
},
// Wrap each table so a wide one scrolls sideways instead of making
// the page wider than a phone screen. Same markup as marked's default.
table (header, body) {
if ( body ) body = `<tbody>${body}</tbody>`;
return `<div class="table-scroll"><table>\n<thead>\n${header}</thead>\n${body}</table>\n</div>\n`;
},
code (code, infostring, escaped) {
// Extract possible playground example ID from the info string
infostring = infostring.trim() || '';
+3 -3
View File
@@ -22,14 +22,14 @@ You can use AI models from various providers to perform tasks such as chat, text
<div class="example-content" data-section="ai-chat" style="display:block;">
#### Chat with GPT-5.6 Luna
#### Chat with GPT-6 Luna
```html;ai-chatgpt
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
puter.ai.chat(`What is life?`, { model: "gpt-5.6-luna" }).then(puter.print);
puter.ai.chat(`What is life?`, { model: "gpt-6-luna" }).then(puter.print);
</script>
</body>
</html>
@@ -212,7 +212,7 @@ These AI features are supported out of the box when using Puter.js:
You can see various Puter.js AI features in action from the following examples:
- AI Chat
- [Chat with GPT-5.6 Luna](/playground/ai-chatgpt/)
- [Chat with GPT-6 Luna](/playground/ai-chatgpt/)
- [Image Analysis](/playground/ai-gpt-vision/)
- [Stream the response](/playground/ai-chat-stream/)
- [Function Calling](/playground/ai-function-calling/)
+241 -181
View File
@@ -27,24 +27,28 @@ A string containing the prompt you want to complete.
An object containing the following properties:
- `model` (String) - The model you want to use for the completion. If not specified, defaults to `gpt-5.4-nano`. More than 500 models are available from vendors including OpenAI, Anthropic, Google, Alibaba Cloud, xAI, Mistral, OpenRouter, Infron, and others. For a full list, see the [AI models list](https://developer.puter.com/ai/models/) page.
- `provider` (String) (Optional) - Pin the request to a specific vendor, for example `openrouter` or `infron`. Without it, Puter selects a vendor for the requested model. Call [`puter.ai.listModelProviders()`](/AI/listModelProviders) for the available values, and [`puter.ai.listModels(provider)`](/AI/listModels) for the models a given vendor serves.
- `fallback` (Boolean) (Optional) - Set to `false` to fail the request when the first vendor tried fails, instead of retrying it on another vendor that serves the same model. Defaults to `true`. With `provider` set as well, the request is answered by that vendor or fails. If that vendor does not serve the model, the request fails with a `400` instead of going to another vendor. The error is the same one you would get if every vendor had failed, for example `400 upstream_provider_unavailable` or `429 upstream_rate_limited`.
- `signal` (AbortSignal) (Optional) - Abort the request, including while waiting for the next streamed chunk. Cancellation rejects with the signal reason (normally an `AbortError`).
- `stream` (Boolean) - A boolean indicating whether you want to stream the completion. Defaults to `false`.
- `max_tokens` (Number) - The maximum number of tokens to generate in the completion. By default, the specific model's maximum is used.
- `temperature` (Number) - A number between 0 and 2 indicating the randomness of the completion. Lower values make the output more focused and deterministic, while higher values make it more random. By default, the model's own temperature is used. Some OpenAI reasoning models ignore `temperature` and `top_p`. GPT-5.6, GPT-6 Sol/Luna, and GPT-6.1 Terra accept them only with `reasoning_effort: 'none'`, and GPT-6 Astra, GPT-6.1 Sol, and GPT-5.3 Codex always ignore them. `gemini-4-argon` ignores `temperature` too. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` use it.
- `tools` (Array) (Optional) - Function definitions the AI can call. See [Function Calling](#function-calling) for details.
- `reasoning_effort` / `reasoning.effort` (String) (Optional) - Controls how much effort reasoning models spend thinking. Supported values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. GPT-6.1 Terra accepts `none`, `low`, `medium` (default), `high`, `xhigh`, and `max`. Lower values give faster responses with less reasoning. OpenAI models, Moonshot's Kimi K3.1 Pro models, Meta's Muse Spark models, `gemini-4-argon`, `gemini-3.1-flash-lite` and `gemini-3-flash-preview` only; Muse Spark always reasons, so `none` is ignored for it. Kimi K3.1 Pro accepts `low`, `high` and `max`, uses `max` when you leave it unset, and fails with `400 bad_request` on any other value. `gemini-4-argon` accepts `low`, `medium` and `high`, and uses `medium` when you leave it unset. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` accept `minimal`, `low`, `medium` and `high`, and use Google's default level when you leave it unset. For these three Gemini models, any other value fails with `400 bad_request`, and messages can contain text and images only.
- `verbosity` / `text.verbosity` (String) (Optional) - Controls how long or short responses are. Supported values: `low`, `medium`, and `high`. Lower values give shorter responses. OpenAI models only.
- `normalize` (Boolean) (Optional) - Controls the format of the non-streaming response. When `true`, the response is normalized to the OpenAI format regardless of the model's vendor: `message.content` is a string, tool calls appear as `message.tool_calls`, and `finish_reason` is one of `stop`, `length`, `tool_calls`, or `content_filter` — or the vendor's own stop reason, passed through unchanged when it has no OpenAI equivalent. When `false`, the response keeps the vendor's native format (for Anthropic models, an array of content blocks). When unset, the SDK-wide `puter.ai.normalize` applies — itself tri-state: set it to `true` to normalize every call regardless of release date, `false` to disable normalization for every call, and when it is left unset too (the default) the release-date policy applies: **models released on or after September 1, 2026 return normalized (OpenAI-format) responses by default**, and for older models the default is unchanged — `message.content` keeps its vendor-native shape. (A handful of reasoning fields were made consistent across all models independently of this option; see [Reasoning fields on existing models](#reasoning-fields-on-existing-models).) Streaming responses are unaffected — chunks already share one format across vendors. See [Response normalization](#response-normalization).
- `compaction` (Boolean | Object) (Optional) - Opt into inline context compaction for long conversations. Pass `true` to enable it with provider defaults, or `{ trigger_tokens: number }` to set the token threshold at which earlier context is summarized. When the model compacts, you receive a `compaction` chunk while streaming (or a `compaction` field on the result when not streaming) containing an opaque `encrypted_content` summary. Resend that item in `messages` on the next turn in place of the summarized history. The compaction chunk shape is identical across providers, so the same code works whether `model` is an OpenAI or Anthropic model. See [Compaction](#compaction).
- `toolChoice` (String | Object) (Optional) - Which tool, if any, the model must call. Pass `"auto"` (the model decides), `"none"` (never call a tool), `"required"` (call at least one), or an object naming a specific tool: `{ type: "tool", name: "..." }`. Requires `tools`.
- `parallelToolCalls` (Boolean) (Optional) - Whether the model may call more than one tool in a single turn. Left unset, tool calls run one at a time.
- `stopSequences` (Array of String) (Optional) - Up to the model's limit of strings; generation stops the moment one of them appears in the output.
- `outputFormat` (Object) (Optional) - Constrains the response to a JSON schema: `{ type: "json_schema", schema, name?, strict? }`.
- `thinking` (Object | null) (Optional) - Extended-thinking control: `{ type: "adaptive" | "enabled" | "disabled" | "between_tools", budgetTokens?, display? }`. Pass `null` to send no thinking configuration at all (the model's own default); leaving it unset falls back to the legacy derivation from `reasoning_effort`. `budgetTokens` only applies to `type: "enabled"`.
- `streamToolInput` (Boolean) (Optional) - Streaming only. When `true`, a tool call's arguments arrive incrementally as `tool_use_start` / `tool_input_delta` chunks instead of a single `tool_use` chunk once the call completes.
| Option | Type | Description |
|--------|------|-------------|
| `model` | String | The model you want to use for the completion. If not specified, defaults to `gpt-5.4-nano`. More than 500 models are available from providers including OpenAI, Anthropic, Google, Alibaba Cloud, xAI, Mistral, OpenRouter, Infron, and others. For a full list, see the [AI models list](https://developer.puter.com/ai/models/) page. |
| `stream` | Boolean | A boolean indicating whether you want to stream the completion. Defaults to `false`. |
| `max_tokens` | Number | The maximum number of tokens to generate in the completion. By default, the specific model's maximum is used. |
| `temperature` | Number | A number between 0 and 2 indicating the randomness of the completion. Lower values make the output more focused and deterministic, while higher values make it more random. By default, the model's own temperature is used. See [Model-specific features](#model-specific-features). |
| `reasoning_effort` / `reasoning.effort` | String | Controls how much effort reasoning models spend thinking. Supported values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. Lower values give faster responses with less reasoning. OpenAI models, Moonshot's Kimi K3.1 Pro models, Meta's Muse Spark models, `gemini-4-argon`, `gemini-3.1-flash-lite` and `gemini-3-flash-preview` only. See [Model-specific features](#model-specific-features). |
| `thinking` | Object \| null | Extended-thinking control: `{ type: "adaptive" \| "enabled" \| "disabled" \| "between_tools", budgetTokens?, display? }`. Pass `null` to send no thinking configuration at all (the model's own default); leaving it unset falls back to the legacy derivation from `reasoning_effort`. `budgetTokens` only applies to `type: "enabled"`. |
| `verbosity` / `text.verbosity` | String | Controls how long or short responses are. Supported values: `low`, `medium`, and `high`. Lower values give shorter responses. OpenAI models only. |
| `tools` | Array | Function definitions the AI can call. See [Function Calling](#function-calling) for details. |
| `toolChoice` | String \| Object | Which tool, if any, the model must call. Pass `"auto"` (the model decides), `"none"` (never call a tool), `"required"` (call at least one), or an object naming a specific tool: `{ type: "tool", name: "..." }`. Requires `tools`. |
| `parallelToolCalls` | Boolean | Whether the model may call more than one tool in a single turn. Left unset, tool calls run one at a time. |
| `streamToolInput` | Boolean | Streaming only. When `true`, a tool call's arguments arrive incrementally as `tool_use_start` / `tool_input_delta` chunks instead of a single `tool_use` chunk once the call completes. |
| `normalize` | Boolean | Controls the format of the non-streaming response. See [Response normalization](#response-normalization). |
| `compaction` | Boolean \| Object | Opt into inline context compaction for long conversations. See [Compaction](#compaction). |
| `signal` | AbortSignal | Abort the request, including while waiting for the next streamed chunk. Cancellation rejects with the signal reason (normally an `AbortError`). |
| `provider` | String | Pin the request to a specific provider, for example `openrouter` or `infron`. Without it, Puter selects a provider for the requested model. Call [`puter.ai.listModelProviders()`](/AI/listModelProviders) for the available values, and [`puter.ai.listModels(provider)`](/AI/listModels) for the models a given provider serves. See [Providers](#providers). |
| `fallback` | Boolean | Set to `false` to fail the request when the first provider tried fails, instead of retrying it on another provider that serves the same model. Defaults to `true`. See [Providers](#providers). |
| `outputFormat` | Object | Constrains the response to a JSON schema: `{ type: "json_schema", schema, name?, strict? }`. |
| `stopSequences` | Array of String | Up to the model's limit of strings; generation stops the moment one of them appears in the output. |
Some models accept additional options or handle these options differently. See [Model-specific features](#model-specific-features).
#### `testMode` (Boolean) (Optional)
@@ -117,6 +121,21 @@ Returns a `Promise` that resolves to either:
- A [`ChatResponse`](/Objects/chatresponse) object containing the chat response data, or
- An async iterable object of [`ChatResponseChunk`](/Objects/chatresponsechunk) (when `stream` is set to `true`) that you can use with a `for await...of` loop to receive the response in parts as they become available.
Break out of the `for await...of` loop to stop reading a chat stream. When using
`new ReadableStream(response)`, call `reader.cancel()` to stop it.
You can also pass an `AbortSignal` to stop waiting for output. See the [stop a stream example](/playground/ai-chat-stop-stream/).
Stopping a stream stops the output your app receives. Once the request has
reached the model, the model still finishes its response, and the whole response
is billed.
The `ReadableStream` adapter emits text chunks as UTF-8 text. Other chunk types
(including usage, tool calls, reasoning and errors) are serialized as JSON followed
by a newline. Use the async iterator to receive every chunk as an object.
## Errors
In case of an error, the `Promise` will reject with an error message.
For streaming responses, an error from the AI provider is returned as a chunk
@@ -133,112 +152,6 @@ the model may already have run (see
[Request timeouts](/rate-limits-and-quotas/#request-timeouts)). Use
`try...catch` around the call and loop to handle these failures.
### Stopping a stream
Break out of the `for await...of` loop to stop reading a chat stream. When using
`new ReadableStream(response)`, call `reader.cancel()` to stop it.
You can also pass an `AbortSignal` to stop waiting for output:
```js
const controller = new AbortController();
const stopButton = document.createElement('button');
stopButton.textContent = 'Stop';
stopButton.onclick = () => controller.abort();
document.body.append(stopButton);
try {
const response = await puter.ai.chat('Write a long story', {
stream: true,
signal: controller.signal,
});
for await (const part of response) {
if (part.type === 'text') console.log(part.text);
}
} catch (error) {
if (error.name !== 'AbortError') throw error;
} finally {
stopButton.remove();
}
```
Stopping a stream stops the output your app receives. Once the request has
reached the model, the model still finishes its response, and the whole response
is billed.
The `ReadableStream` adapter emits text chunks as UTF-8 text. Other chunk types
(including usage, tool calls, reasoning and errors) are serialized as JSON followed
by a newline. Use the async iterator to receive every chunk as an object.
## Vendors
We use different vendors for different models and try to use the best vendor available at the time of the request. Vendors currently include Alibaba Cloud, Anthropic, Azure OpenAI, DeepSeek, Google, Infron, Meta, MiniMax, Mistral, Moonshot AI, OpenAI, OpenRouter, Together AI, xAI, and Z.AI. Call [`puter.ai.listModelProviders()`](/AI/listModelProviders) for the current list, or pass `provider` in the options object to pin a request to one of them.
When the vendor serving a request fails, Puter retries the request on the next vendor that serves the same model (up to three attempts), and you are billed for the attempt that answers. Pass `fallback: false` to turn this off, so that a failure from the first vendor tried is returned to you instead:
```js
// Answered by Anthropic directly, or an error. Never sent to another vendor.
const response = await puter.ai.chat('Hello', {
model: 'claude-sonnet-4-6',
provider: 'claude',
fallback: false,
});
```
## Response Normalization
Most vendors respond in the OpenAI chat format, where `message.content` is a string and tool calls appear as `message.tool_calls`. Anthropic models historically respond in Anthropic's native format instead, where `message.content` is an array of content blocks such as `[{ type: "text", text: "..." }]`.
**Going forward, all models released on or after September 1, 2026 return responses in the OpenAI format**, no matter which vendor serves them — so the same response-handling code works across every new model. For models released before that date, the `normalize` default does not change: leave the option unset and `message.content` keeps its vendor-native shape.
### Reasoning fields on existing models
Separately from the `normalize` default, four reasoning-related fields were made consistent across vendors. These apply to **every** model, including ones released before the cutoff, and are not affected by `normalize`:
| Field | Before | Now |
| --- | --- | --- |
| `message.reasoning_content` | Present on providers following the DeepSeek convention (DeepSeek, OpenRouter and others) | **Renamed to `message.reasoning`.** Read `reasoning` instead — `reasoning_content` is no longer present on non-streaming responses. |
| `message.reasoning` on OpenAI Responses models | Always present as `null` | Absent when the model returned no reasoning summary; a string when it did. `if (msg.reasoning)` is unaffected; `'reasoning' in msg` changes. |
| `message.reasoning_details` on OpenAI Responses models | Not present | Present when the model returned reasoning items, carrying their `id` and `encrypted_content` for replay. |
| `finish_reason` on OpenAI Responses models | Always `"stop"` | `"tool_calls"` when the turn ended in tool calls, `"stop"` otherwise. |
If your code reads `message.reasoning_content` on a non-streaming response, that is the one change that removes a field — switch to `message.reasoning`.
You can control this per call with the `normalize` option:
```js
// Force the OpenAI format on any model, old or new:
const response = await puter.ai.chat("Hello", { model: "claude-sonnet-5", normalize: true });
console.log(response.message.content); // a string, or null on a tool-only turn
console.log(response.finish_reason); // "stop" | "length" | "tool_calls" | "content_filter" | vendor value
// Force the vendor-native format, even on a post-cutoff model:
const native = await puter.ai.chat("Hello", { model: "claude-sonnet-5", normalize: false });
```
Or SDK-wide with `puter.ai.normalize`:
```js
// Unset (the default): the release-date rule applies — models released on or
// after September 1, 2026 return the OpenAI format, older models stay native.
puter.ai.normalize = true; // force normalization: every chat() call
// returns the OpenAI format, old or new model
puter.ai.normalize = false; // disable normalization: every chat() call
// returns the vendor-native format
puter.ai.normalize = undefined; // back to the release-date rule
```
A `normalize` option on an individual call always overrides `puter.ai.normalize` in either direction. Normalized responses carry `normalized: true`.
On a normalized response, extended-thinking output (from reasoning models that expose it) is joined into `message.reasoning`, and Anthropic stop reasons are mapped to OpenAI values (`end_turn` → `stop`, `max_tokens` → `length`, `tool_use` → `tool_calls`, `refusal` → `content_filter`). A vendor stop reason with no OpenAI equivalent — Anthropic's `pause_turn`, for instance — passes through unchanged, so treat `finish_reason` as an open set. See [`finish_reason`](/Objects/chatresponse) for the full mapping.
Normalization does not cost you the ability to continue a reasoning turn. The opaque parts a provider needs back — Anthropic thinking-block signatures, OpenAI reasoning item ids and encrypted content — are preserved verbatim on `message.reasoning_details`. Resend that array as-is alongside the message when you continue an extended-thinking tool-use loop. The artifacts are vendor-specific and only meaningful to the model that produced them, so replay them to the same model — don't carry them across vendors. Kimi K3.1 Pro reads its `reasoning` string back the same way, so keep `reasoning` on the assistant message when you continue a conversation with it.
One caveat. The release-date rule applies to the model that actually serves the request — if a request is rerouted to a fallback provider, the served model's release date decides.
One thing to know about the release-date rule: a model's release date comes from the catalog of whichever provider serves it, and some providers report it from their own live listing. Models served through OpenRouter carry the date OpenRouter itself assigns, so a model newly listed there on or after September 1, 2026 is normalized by default without Puter shipping any change. Pin `normalize: false` if your code depends on a provider's native shape.
Streaming is unaffected by normalization: streamed [`ChatResponseChunk`](/Objects/chatresponsechunk) objects already share one format across all vendors, and the chunk types a model emits do not depend on the `normalize` option. Reasoning models stream their thinking as `reasoning` chunks on every provider, whether or not normalization applies.
## Function Calling
Function calling (also known as tool calling) allows AI models to request data or perform actions by calling functions you define. This enables the AI to access real-time information, interact with external systems, and perform tasks beyond its training data.
@@ -269,51 +182,10 @@ After executing the function, send the result back by including a message with:
- `tool_call_id` (String) - The `id` from the tool call
- `content` (String) - The function result as a string
Gemini models can attach `extra_content.google.thought_signature` to a tool call. Send the assistant message back with its `tool_calls` unchanged, including `extra_content`, so the model can continue its reasoning. In a stream, the signature arrives on the `tool_use` chunk's `extra_content`.
See also [Gemini models](#gemini-models).
See the [Function Calling example](/playground/ai-function-calling/) for a complete working implementation.
### Web Search
Specific to OpenAI models, you can use the built-in web search tool, allowing the AI to access up-to-date information from the internet.
Pass in the `tools` parameter with the type of `web_search`.
```js
{
model: 'openai/gpt-5.6-luna',
tools: [{type: "web_search"}]
}
```
The code implementation is available in our [web search example](/playground/ai-web-search/).
List of OpenAI models that support the web search can be found in their [API compatibility documentation](https://platform.openai.com/docs/guides/tools-web-search#api-compatibility).
## Prompt Caching
Specific to Anthropic models, you can use the cache control feature, allowing you to optimize costs for repeated prompts.
Pass in the `cache_control` parameter inside the object in the `messages` array.
```js
[
{
role: 'system',
content: 'a really long system prompt',
cache_control: { type: "ephemeral" }
},
{
role: 'user',
content: '<your message>'
},
]
```
You can find the implementation in our [prompt caching example](/playground/ai-claude-cache-control/). Find more details about cache control in [Anthropic documentation](https://platform.claude.com/docs/en/build-with-claude/prompt-caching).
Moonshot's Kimi K3 and Kimi K3.1 Pro models cache prompt prefixes on their own and take no `cache_control`. Their `usage` reports the tokens read from the cache as `cached_tokens` and the tokens written to it as `cache_write_tokens`. Each is billed at the model's own rate from the [AI models list](https://developer.puter.com/ai/models/).
## Compaction
For long, multi-turn conversations that you keep on the client, enable
@@ -426,7 +298,146 @@ if ( result.compaction ) {
`trigger_tokens` below a provider's minimum) arrive as an `error` chunk, not a
thrown exception.
## Image Generation (Gemini Image Models)
## Response Normalization
Most providers respond in the OpenAI chat format, where `message.content` is a string and tool calls appear as `message.tool_calls`. Anthropic models historically respond in Anthropic's native format instead, where `message.content` is an array of content blocks such as `[{ type: "text", text: "..." }]`.
**Going forward, all models released on or after September 1, 2026 return responses in the OpenAI format**, no matter which provider serves them — so the same response-handling code works across every new model. For models released before that date, the `normalize` default does not change: leave the option unset and `message.content` keeps its provider-native shape.
**Reasoning fields on existing models**
Separately from the `normalize` default, four reasoning-related fields were made consistent across providers. These apply to **every** model, including ones released before the cutoff, and are not affected by `normalize`:
| Field | Before | Now |
| --- | --- | --- |
| `message.reasoning_content` | Present on providers following the DeepSeek convention (DeepSeek, OpenRouter and others) | **Renamed to `message.reasoning`.** Read `reasoning` instead — `reasoning_content` is no longer present on non-streaming responses. |
| `message.reasoning` on OpenAI Responses models | Always present as `null` | Absent when the model returned no reasoning summary; a string when it did. `if (msg.reasoning)` is unaffected; `'reasoning' in msg` changes. |
| `message.reasoning_details` on OpenAI Responses models | Not present | Present when the model returned reasoning items, carrying their `id` and `encrypted_content` for replay. |
| `finish_reason` on OpenAI Responses models | Always `"stop"` | `"tool_calls"` when the turn ended in tool calls, `"stop"` otherwise. |
If your code reads `message.reasoning_content` on a non-streaming response, that is the one change that removes a field — switch to `message.reasoning`.
You can control this per call with the `normalize` option:
```js
// Force the OpenAI format on any model, old or new:
const response = await puter.ai.chat("Hello", { model: "claude-sonnet-5", normalize: true });
console.log(response.message.content); // a string, or null on a tool-only turn
console.log(response.finish_reason); // "stop" | "length" | "tool_calls" | "content_filter" | provider value
// Force the provider-native format, even on a post-cutoff model:
const native = await puter.ai.chat("Hello", { model: "claude-sonnet-5", normalize: false });
```
Or SDK-wide with `puter.ai.normalize`:
```js
// Unset (the default): the release-date rule applies — models released on or
// after September 1, 2026 return the OpenAI format, older models stay native.
puter.ai.normalize = true; // force normalization: every chat() call
// returns the OpenAI format, old or new model
puter.ai.normalize = false; // disable normalization: every chat() call
// returns the provider-native format
puter.ai.normalize = undefined; // back to the release-date rule
```
A `normalize` option on an individual call always overrides `puter.ai.normalize` in either direction. Normalized responses carry `normalized: true`.
On a normalized response, extended-thinking output (from reasoning models that expose it) is joined into `message.reasoning`, and Anthropic stop reasons are mapped to OpenAI values (`end_turn` → `stop`, `max_tokens` → `length`, `tool_use` → `tool_calls`, `refusal` → `content_filter`). A provider stop reason with no OpenAI equivalent — Anthropic's `pause_turn`, for instance — passes through unchanged, so treat `finish_reason` as an open set. See [`finish_reason`](/Objects/chatresponse) for the full mapping.
Normalization does not cost you the ability to continue a reasoning turn. The opaque parts a provider needs back — Anthropic thinking-block signatures, OpenAI reasoning item ids and encrypted content — are preserved verbatim on `message.reasoning_details`. Resend that array as-is alongside the message when you continue an extended-thinking tool-use loop. The artifacts are provider-specific and only meaningful to the model that produced them, so replay them to the same model — don't carry them across providers. Kimi K3.1 Pro reads its `reasoning` string back the same way, so keep `reasoning` on the assistant message when you continue a conversation with it.
One caveat. The release-date rule applies to the model that actually serves the request — if a request is rerouted to a fallback provider, the served model's release date decides.
One thing to know about the release-date rule: a model's release date comes from the catalog of whichever provider serves it, and some providers report it from their own live listing. Models served through OpenRouter carry the date OpenRouter itself assigns, so a model newly listed there on or after September 1, 2026 is normalized by default without Puter shipping any change. Pin `normalize: false` if your code depends on a provider's native shape.
Streaming is unaffected by normalization: streamed [`ChatResponseChunk`](/Objects/chatresponsechunk) objects already share one format across all providers, and the chunk types a model emits do not depend on the `normalize` option. Reasoning models stream their thinking as `reasoning` chunks on every provider, whether or not normalization applies.
## Providers
We use different providers for different models and try to use the best provider available at the time of the request. Providers currently include Alibaba Cloud, Anthropic, Azure OpenAI, DeepSeek, Google, Infron, Meta, MiniMax, Mistral, Moonshot AI, OpenAI, OpenRouter, Together AI, xAI, and Z.AI. Call [`puter.ai.listModelProviders()`](/AI/listModelProviders) for the current list, or pass `provider` in the options object to pin a request to one of them.
When the provider serving a request fails, Puter retries the request on the next provider that serves the same model (up to three attempts), and you are billed for the attempt that answers. Pass `fallback: false` to turn this off, so that a failure from the first provider tried is returned to you instead:
```js
// Answered by Anthropic directly, or an error. Never sent to another provider.
const response = await puter.ai.chat('Hello', {
model: 'claude-sonnet-4-6',
provider: 'claude',
fallback: false,
});
```
With `provider` set as well, the request is answered by that provider or fails. If that provider does not serve the model, the request fails with a `400` instead of going to another provider. The error is the same one you would get if every provider had failed, for example `400 upstream_provider_unavailable` or `429 upstream_rate_limited`.
## Model-specific features
### OpenAI models
#### `temperature`
Some OpenAI reasoning models ignore `temperature` and `top_p`. GPT-5.6, GPT-6 Sol/Luna, and GPT-6.1 Terra accept them only with `reasoning_effort: 'none'`, and GPT-6 Astra, GPT-6.1 Sol, and GPT-5.3 Codex always ignore them.
#### `reasoning_effort`
GPT-6.1 Terra accepts `none`, `low`, `medium` (default), `high`, `xhigh`, and `max`.
#### Web Search
Specific to OpenAI models, you can use the built-in web search tool, allowing the AI to access up-to-date information from the internet.
Pass in the `tools` parameter with the type of `web_search`.
```js
{
model: 'openai/gpt-6-luna',
tools: [{type: "web_search"}]
}
```
The code implementation is available in our [web search example](/playground/ai-web-search/).
List of OpenAI models that support the web search can be found in their [API compatibility documentation](https://platform.openai.com/docs/guides/tools-web-search#api-compatibility).
### Anthropic models
#### Prompt Caching
Specific to Anthropic models, you can use the cache control feature, allowing you to optimize costs for repeated prompts.
Pass in the `cache_control` parameter inside the object in the `messages` array.
```js
[
{
role: 'system',
content: 'a really long system prompt',
cache_control: { type: "ephemeral" }
},
{
role: 'user',
content: '<your message>'
},
]
```
You can find the implementation in our [prompt caching example](/playground/ai-claude-cache-control/). Find more details about cache control in [Anthropic documentation](https://platform.claude.com/docs/en/build-with-claude/prompt-caching).
### Gemini models
#### `temperature`
`gemini-4-argon` ignores `temperature`. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` use it.
#### `reasoning_effort`
`gemini-4-argon` accepts `low`, `medium` and `high`, and uses `medium` when you leave it unset. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` accept `minimal`, `low`, `medium` and `high`, and use Google's default level when you leave it unset. For these three Gemini models, any other value fails with `400 bad_request`, and messages can contain text and images only.
#### Function calling
Gemini models can attach `extra_content.google.thought_signature` to a tool call. Send the assistant message back with its `tool_calls` unchanged, including `extra_content`, so the model can continue its reasoning. In a stream, the signature arrives on the `tool_use` chunk's `extra_content`.
### Gemini image models
Certain Gemini models can generate and edit images as part of a chat conversation. These models accept text and image inputs, and return text and images in the response.
@@ -513,22 +524,38 @@ for await (const part of resp) {
}
```
### Moonshot Kimi models
#### `reasoning_effort`
Kimi K3.1 Pro accepts `low`, `high` and `max`, uses `max` when you leave it unset, and fails with `400 bad_request` on any other value.
#### Prompt Caching
Moonshot's Kimi K3 and Kimi K3.1 Pro models cache prompt prefixes on their own and take no `cache_control`. Their `usage` reports the tokens read from the cache as `cached_tokens` and the tokens written to it as `cache_write_tokens`. Each is billed at the model's own rate from the [AI models list](https://developer.puter.com/ai/models/).
### Meta Muse Spark models
#### `reasoning_effort`
Muse Spark always reasons, so `none` is ignored for it.
## Examples
<strong class="example-title">Ask GPT-5.6 Luna a question</strong>
### Ask GPT-6 Luna a question
```html;ai-chatgpt
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
puter.ai.chat(`What is life?`, { model: "gpt-5.6-luna" }).then(puter.print);
puter.ai.chat(`What is life?`, { model: "gpt-6-luna" }).then(puter.print);
</script>
</body>
</html>
```
<strong class="example-title">Image Analysis</strong>
### Image Analysis
```html;ai-gpt-vision
<html>
@@ -538,7 +565,7 @@ for await (const part of resp) {
<script>
puter.ai
.chat(`What do you see?`, `https://assets.puter.site/doge.jpeg`, {
model: "gpt-5.6-luna",
model: "gpt-6-luna",
})
.then(puter.print);
</script>
@@ -546,7 +573,7 @@ for await (const part of resp) {
</html>
```
<strong class="example-title">Video Analysis</strong>
### Video Analysis
```html;ai-video-analysis
<html>
@@ -563,7 +590,7 @@ for await (const part of resp) {
</html>
```
<strong class="example-title">Stream the response</strong>
### Stream the response
```html;ai-chat-stream
<html>
@@ -579,7 +606,40 @@ for await (const part of resp) {
</html>
```
<strong class="example-title">Function Calling</strong>
### Stop a stream
```html;ai-chat-stop-stream
<html>
<body>
<button id="stop">Stop</button>
<div id="output" style="white-space: pre-wrap"></div>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const controller = new AbortController();
const stopButton = document.getElementById('stop');
const output = document.getElementById('output');
stopButton.onclick = () => controller.abort();
try {
const response = await puter.ai.chat('Write a long story', {
stream: true,
signal: controller.signal,
});
for await (const part of response) {
if (part.type === 'text') output.append(part.text);
}
} catch (error) {
if (error.name !== 'AbortError') throw error;
} finally {
stopButton.remove();
}
})();
</script>
</body>
</html>
```
### Function Calling
```html;ai-function-calling
<html>
@@ -639,7 +699,7 @@ for await (const part of resp) {
</html>
```
<strong class="example-title">Streaming Function Calling</strong>
### Streaming Function Calling
```html;ai-streaming-function-calling
<html>
@@ -721,7 +781,7 @@ for await (const part of resp) {
</html>
```
<strong class="example-title">Web Search</strong>
### Web Search
```html;ai-web-search
<html>
@@ -731,7 +791,7 @@ for await (const part of resp) {
puter.print(`Loading...`);
puter.ai
.chat("Summarize what the User-Pays Model is: https://docs.puter.com/user-pays-model/", {
model: "openai/gpt-5.6-luna",
model: "openai/gpt-6-luna",
tools: [{ type: "web_search" }],
})
.then(puter.print);
@@ -740,7 +800,7 @@ for await (const part of resp) {
</html>
```
<strong class="example-title">Prompt caching with Claude</strong>
### Prompt caching with Claude
```html;ai-claude-cache-control
<html>
@@ -795,7 +855,7 @@ Policy 8 - Account Management: Each Enterprise and Ultimate customer is assigned
</html>
```
<strong class="example-title">Image Generation</strong>
### Image Generation
```html;ai-image-chat
<html>
@@ -820,7 +880,7 @@ Policy 8 - Account Management: Each Enterprise and Ultimate customer is assigned
</html>
```
<strong class="example-title">Multi-Turn Image Editing</strong>
### Multi-Turn Image Editing
```html;ai-image-edit
<html>
@@ -872,7 +932,7 @@ Policy 8 - Account Management: Each Enterprise and Ultimate customer is assigned
</html>
```
<strong class="example-title">Working with Files</strong>
### Working with Files
```html;ai-resume-analyzer
<!DOCTYPE html>
+47 -53
View File
@@ -4,7 +4,9 @@ description: Extract text from images and documents using OCR to read printed te
platforms: [websites, apps, nodejs, workers]
---
Given an image or document, returns the text it contains. Also known as OCR (Optical Character Recognition), this API can be used to extract text from images of printed text, handwriting, or any other text-based content. AWS Textract is the default; Mistral OCR reads multi-page documents and more file formats, returns Markdown, and can fill a JSON schema from the document.
Given an image or document, returns the text it contains.
Also known as OCR (Optical Character Recognition), this API can be used to extract text from images of printed text, handwriting, or any other text-based content. AWS Textract is the default; Mistral OCR reads multi-page documents and more file formats, returns Markdown, and can fill a JSON schema from the document.
## Syntax
@@ -30,60 +32,15 @@ Every call has the same shape; only the model name changes which service reads t
| Option | Type | Description |
|--------|------|-------------|
| `model` | `String` | The OCR model to use (see [Models](#models)). The model picks its provider, so `provider` is not needed alongside it. Lookups are case-insensitive. |
| `provider` | `String` | `'aws-textract'` (default) or `'mistral'`. Aliases `'aws'`, `'textract'` and `'mistral-ocr'` are also accepted. Without a `model`, the provider's default model runs. When both are given, the model must belong to the provider. |
| `model` | `String` | The OCR model to use. See [Model-specific features](#model-specific-features) for the models. The model picks its provider, so `provider` is not needed alongside it. Lookups are case-insensitive. |
| `provider` | `String` | Either `'aws-textract'` (default) or `'mistral'`. Aliases `'aws'`, `'textract'` and `'mistral-ocr'` are also accepted. Without a `model`, the provider's default model runs. When both are given, the model must belong to the provider. |
| `testMode` | `Boolean` | When `true`, returns a sample response without using credits. Defaults to `false` |
#### Models
| Model | Provider | Notes |
|-------|----------|-------|
| `aws-textract` (alias `textract`) | AWS Textract | Default. Plain text, one line per detected line. |
| `mistral-ocr-latest` (aliases `mistral-ocr-4`, `mistral-ocr-4-1`) | Mistral | Mistral OCR 4.1, the default Mistral model. Markdown output. |
| `mistral-ocr-2512` (aliases `mistral-ocr-3`, `mistral-ocr-3-0`) | Mistral | Mistral OCR 3, at a lower per-page rate than OCR 4. |
`mistral-ocr-latest` is pinned to OCR 4.1 and moves to a newer model only when Puter adds it. Puter rejects the deprecated `mistral-ocr-2503` and `mistral-ocr-2505` and the retired `mistral-ocr-4-0` with `bad_request`.
#### AWS Textract options
AWS Textract takes no options beyond `model` and `provider`. It reads a single-page document as a whole, so there is nothing to select or tune; the Mistral options below are ignored.
#### Mistral options
These options apply to Mistral models. AWS Textract ignores them.
| Option | Type | Description |
|--------|------|-------------|
| `pages` | `Array<Number>` | Pages to process, as 0-based indexes |
| `includeImageBase64` | `Boolean` | Include extracted images in the provider response |
| `imageLimit` | `Number` | Maximum number of images to extract |
| `imageMinSize` | `Number` | Minimum height and width of an image to extract |
| `tableFormat` | `String` | `'markdown'` or `'html'`: extract tables in that format instead of leaving them inline |
| `extractHeader` | `Boolean` | Move page headers out of the returned text |
| `extractFooter` | `Boolean` | Move page footers out of the returned text |
| `documentAnnotationFormat` | `Object` | A JSON schema for a document-level annotation: `{ type: 'json_schema', json_schema: { name, schema } }`. When set, `img2txt()` resolves to the annotation instead of the text. Billed per page on top of OCR. |
| `documentAnnotationPrompt` | `String` | Instructions that guide the document annotation |
| `bboxAnnotationFormat` | `Object` | A JSON schema, in the same shape, for annotating each extracted image. Billed per page on top of OCR. |
In both annotation formats, `json_schema` may also be spelled `jsonSchema` and `schema` may be spelled `schemaDefinition`. Any other shape is rejected with `bad_request` before anything is billed.
For more details about each option, see the [Mistral OCR documentation](https://docs.mistral.ai/api/endpoint/ocr).
#### Input limits
| Model | Formats | Maximum size |
|-------|---------|--------------|
| AWS Textract | JPEG, PNG, TIFF, and **single-page** PDF | 10 MB |
| Mistral | PDF (up to 1,000 pages), images (JPEG, PNG, AVIF, TIFF, GIF, HEIC, BMP, WebP), and documents such as DOCX, PPTX, XLSX, EPUB and RTF | 50 MB |
The SDK checks the decoded size of data URI inputs against the selected model's limit before upload: 10 MB for Textract and 36 MB when a Mistral model or provider is specified (a data URI is a third larger than the file, and a request body is capped at 50 MB). URLs and Puter paths are limited by the backend. When neither model nor provider is specified, the SDK checks against 10 MB; explicitly select Mistral for larger inline inputs. Size failures use `input_too_large`. Textract rejects multi-page PDFs and other formats with `bad_request`; use a Mistral model for those.
Some models accept additional options or handle these options differently. See [Model-specific features](#model-specific-features).
## Return value
A `Promise` that resolves to a string.
- By default the string is the recognized text, one line per line: plain text from AWS Textract, Markdown from Mistral.
- When `documentAnnotationFormat` is set, the string is the document annotation: JSON that follows your schema.
A `Promise` that resolves to a string, the recognized text, one line per line. The text's format depends on the model, and some model options change what the string holds. See [Model-specific features](#model-specific-features).
The return shape is the same for every model.
@@ -101,9 +58,46 @@ A rejection carries the error body as the backend sent it: `{ message, code }`.
Other `upstream_*` codes mean the provider rejected the request or was unavailable; the `message` carries the provider's reason.
## Input limits
Each model accepts its own formats and sizes, listed in its section under [Model-specific features](#model-specific-features). The SDK checks the decoded size of a data URI against the selected model's limit before upload. When neither `model` nor `provider` is specified, it checks against the default model's limit. URLs and Puter paths are limited by the backend. An input over the limit fails with `input_too_large`.
## Model-specific features
### AWS Textract models
The model is `aws-textract` (alias `textract`), the default for every call. It returns plain text, one line per detected line.
AWS Textract reads JPEG, PNG, TIFF, and **single-page** PDF, up to 10 MB. It rejects multi-page PDFs and other formats with `bad_request`. Use a Mistral model for those.
AWS Textract takes no options beyond `model` and `provider`. It reads a single-page document as a whole, so there is nothing to select or tune; the Mistral options below are ignored.
### Mistral models
The default Mistral model is `mistral-ocr-latest` (aliases `mistral-ocr-4`, `mistral-ocr-4-1`), which is Mistral OCR 4.1. It stays on OCR 4.1 and moves to a newer model only when Puter adds it. Mistral OCR 3 is `mistral-ocr-2512` (aliases `mistral-ocr-3`, `mistral-ocr-3-0`), at a lower per-page rate than OCR 4. Both return Markdown.
Mistral reads PDF (up to 1,000 pages), images (JPEG, PNG, AVIF, TIFF, GIF, HEIC, BMP, WebP), and documents such as DOCX, PPTX, XLSX, EPUB and RTF, up to 50 MB. The SDK checks a data URI against 36 MB, because a data URI is a third larger than the file and a request body is capped at 50 MB. Select a Mistral model or provider to send a data URI over 10 MB, since without either the SDK checks against AWS Textract's limit.
| Option | Type | Description |
|--------|------|-------------|
| `pages` | `Array<Number>` | Pages to process, as 0-based indexes |
| `includeImageBase64` | `Boolean` | Include extracted images in the provider response |
| `imageLimit` | `Number` | Maximum number of images to extract |
| `imageMinSize` | `Number` | Minimum height and width of an image to extract |
| `tableFormat` | `String` | Either `'markdown'` or `'html'`: extract tables in that format instead of leaving them inline |
| `extractHeader` | `Boolean` | Move page headers out of the returned text |
| `extractFooter` | `Boolean` | Move page footers out of the returned text |
| `documentAnnotationFormat` | `Object` | A JSON schema for a document-level annotation: `{ type: 'json_schema', json_schema: { name, schema } }`. When set, `img2txt()` resolves to the annotation instead of the text, as JSON that follows your schema. Billed per page on top of OCR. |
| `documentAnnotationPrompt` | `String` | Instructions that guide the document annotation |
| `bboxAnnotationFormat` | `Object` | A JSON schema, in the same shape, for annotating each extracted image. Billed per page on top of OCR. |
In both annotation formats, `json_schema` may also be spelled `jsonSchema` and `schema` may be spelled `schemaDefinition`. Any other shape is rejected with `bad_request` before anything is billed.
For more details about each option, see the [Mistral OCR documentation](https://docs.mistral.ai/api/endpoint/ocr).
## Examples
<strong class="example-title">Extract the text contained in an image</strong>
### Extract the text contained in an image
```html;ai-img2txt
<html>
@@ -116,7 +110,7 @@ Other `upstream_*` codes mean the provider rejected the request or was unavailab
</html>
```
<strong class="example-title">Read the same image with Mistral OCR</strong>
### Read the same image with Mistral OCR
```html;ai-img2txt-mistral
<html>
@@ -131,7 +125,7 @@ Other `upstream_*` codes mean the provider rejected the request or was unavailab
</html>
```
<strong class="example-title">Extract structured data with a document annotation</strong>
### Extract structured data with a document annotation
```html;ai-img2txt-annotation
<html>
+1 -1
View File
@@ -18,7 +18,7 @@ Connect to this URL on the Puter API host:
wss://api.puter.com/puterai/openai/v1/realtime?model=gpt-realtime-2.1-mini
```
Self-hosted clients use their deployment's API host. The `model` query parameter defaults to `gpt-realtime-2.1-mini`. The model is fixed for the connection. A session update that changes it is rejected. It also accepts `gpt-realtime-2.1`, `gpt-realtime-2`, `gpt-realtime-1.5`, `gpt-realtime`, and `gpt-realtime-mini`.
The `model` query parameter defaults to `gpt-realtime-2.1-mini`. The model is fixed for the connection. A session update that changes it is rejected. It also accepts `gpt-realtime-2.1`, `gpt-realtime-2`, `gpt-realtime-1.5`, `gpt-realtime`, and `gpt-realtime-mini`.
Authenticate with a Puter session, app, or full-access API token. Puter supplies the provider credential. Server clients pass this header:
+17 -15
View File
@@ -4,7 +4,7 @@ description: Transform an audio clip into a different voice using ElevenLabs spe
platforms: [websites, apps, nodejs, workers]
---
Convert an existing recording into another voice while preserving timing, pacing, and delivery. Uses ElevenLabs voices.
Convert an existing recording into another voice while preserving timing, pacing, and delivery.
## Syntax
@@ -29,17 +29,19 @@ Audio to convert. Accepts:
Fine-tune the conversion:
- `audio` (String | File | Blob): Alternate way to provide the source input.
- `voice` (String): Target ElevenLabs voice ID. Defaults to `21m00Tcm4TlvDq8ikWAM`.
- `model` (String): Voice-changer model. Defaults to `eleven_multilingual_sts_v2`. You can also use `eleven_english_sts_v2` for English-only inputs.
- `output_format` (String): Desired output codec and bitrate, e.g. `mp3_44100_128`, `opus_48000_64`, or `pcm_48000`. Defaults to `mp3_44100_128`.
- `voice_settings` (Object|String): ElevenLabs voice settings payload (e.g. `{"stability":0.5,"similarity_boost":0.75}`).
- `seed` (Number): Randomization seed for deterministic outputs.
- `remove_background_noise` (Boolean): Apply background noise removal.
- `file_format` (String): Input file format hint (e.g. `pcm_s16le_16`) for raw PCM streams.
- `optimize_streaming_latency` (Number): Latency optimization level (0–4) forwarded to ElevenLabs.
- `enable_logging` (Boolean): Forwarded to ElevenLabs to toggle zero-retention logging behavior.
- `test_mode` (Boolean): When `true`, returns a sample response without using credits. Defaults to `false`.
| Option | Type | Description |
|--------|------|-------------|
| `voice` | `String` | Target ElevenLabs voice ID. Defaults to `21m00Tcm4TlvDq8ikWAM`. |
| `model` | `String` | Voice-changer model. Defaults to `eleven_multilingual_sts_v2`. You can also use `eleven_english_sts_v2` for English-only inputs. |
| `output_format` | `String` | Desired output codec and bitrate, e.g. `mp3_44100_128`, `opus_48000_64`, or `pcm_48000`. Defaults to `mp3_44100_128`. |
| `voice_settings` | `Object \| String` | ElevenLabs voice settings payload (e.g. `{"stability":0.5,"similarity_boost":0.75}`). |
| `remove_background_noise` | `Boolean` | Apply background noise removal. |
| `seed` | `Number` | Randomization seed for deterministic outputs. |
| `file_format` | `String` | Input file format hint (e.g. `pcm_s16le_16`) for raw PCM streams. |
| `optimize_streaming_latency` | `Number` | Latency optimization level (0–4) forwarded to ElevenLabs. |
| `enable_logging` | `Boolean` | Forwarded to ElevenLabs to toggle zero-retention logging behavior. |
| `audio` | `String \| File \| Blob` | Alternate way to provide the source input. |
| `test_mode` | `Boolean` | When `true`, returns a sample response without using credits. Defaults to `false`. |
#### `testMode` (Boolean) (optional)
@@ -51,7 +53,7 @@ A `Promise` that resolves to an `HTMLAudioElement`. Call `audio.play()` or use t
## Examples
<strong class="example-title">Change the voice of a sample clip</strong>
### Change the voice of a sample clip
```html;ai-speech2speech-url
<html>
@@ -69,7 +71,7 @@ A `Promise` that resolves to an `HTMLAudioElement`. Call `audio.play()` or use t
</html>
```
<strong class="example-title">Convert a recording stored as a file</strong>
### Convert a recording stored as a file
```html;ai-speech2speech-file
<html>
@@ -98,7 +100,7 @@ A `Promise` that resolves to an `HTMLAudioElement`. Call `audio.play()` or use t
</html>
```
<strong class="example-title">Develop with test mode</strong>
### Develop with test mode
```html
<html>
+61 -41
View File
@@ -4,7 +4,9 @@ description: Transcribe audio into text using OpenAI, xAI or Gemini speech-to-te
platforms: [websites, apps, nodejs, workers]
---
Converts spoken audio into text, with speaker diarization on xAI and Gemini. Supports OpenAI, xAI and Gemini models.
Converts spoken audio into text.
Supports OpenAI, xAI and Gemini models, with speaker diarization on xAI and Gemini.
## Syntax
@@ -31,42 +33,22 @@ When you omit `source`, supply `options.file` or `options.audio` instead.
Fine-tune how transcription runs.
- `file` / `audio` (String | File | Blob): Alternative way to pass the audio input.
- `provider` (String): STT provider to use. `'openai'` (default), `'xai'` or `'gemini'`. Aliases `'whisper'`, `'grok'`, `'x-ai'`, `'google'` and `'gemini-speech2txt'` are also accepted; anything else is rejected with a `bad_request` error.
- `model` (String): `gpt-transcribe` (OpenAI default), `grok-voice-transcribe-2.0` with `provider: 'xai'`, or `gemini-3.5-transcribe` (the only Gemini model) with `provider: 'gemini'`. OpenAI deprecated `whisper-1`, `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`, and `gpt-4o-transcribe-diarize`; Puter rejects them with `bad_request`.
- `translate` (Boolean): Not supported. On OpenAI and Gemini, `translate: true` fails with `bad_request`; on xAI it returns an ordinary transcript in the source language.
- `response_format` (String): `json` (default) or `text` for OpenAI and Gemini.
- `language` (String): ISO language code hint for the input audio.
- `prompt` (String): Optional context to guide the transcription.
- `temperature` (Number): Sampling temperature (0–1) for supported models.
- `logprobs` (Boolean): Request token log probabilities where supported.
- `extra_body` (Object): Forwarded verbatim to the OpenAI API for experimental flags.
- `stream` (Boolean): Reserved for future streaming support. Streaming is not currently supported.
- `test_mode` (Boolean): When `true`, returns a sample response without using credits. Defaults to `false`.
| Option | Type | Description |
|--------|------|-------------|
| `provider` | `String` | STT provider to use. One of `'openai'` (default), `'xai'` or `'gemini'`. Aliases `'whisper'`, `'grok'`, `'x-ai'`, `'google'` and `'gemini-speech2txt'` are also accepted; anything else is rejected with a `bad_request` error. |
| `model` | `String` | Model to use. Each provider's models are listed under [Model-specific features](#model-specific-features). |
| `language` | `String` | ISO language code hint for the input audio. |
| `response_format` | `String` | Either `json` (default) or `text` for OpenAI and Gemini. |
| `prompt` | `String` | Optional context to guide the transcription. |
| `temperature` | `Number` | Sampling temperature (0–1) for supported models. |
| `logprobs` | `Boolean` | Request token log probabilities where supported. |
| `file` | `String \| File \| Blob` | Alternative way to pass the audio input. |
| `audio` | `String \| File \| Blob` | Same as `file`. |
| `translate` | `Boolean` | Not supported. On OpenAI and Gemini, `translate: true` fails with `bad_request`; on xAI it returns an ordinary transcript in the source language. |
| `stream` | `Boolean` | Reserved for future streaming support. Streaming is not currently supported. |
| `test_mode` | `Boolean` | When `true`, returns a sample response without using credits. Defaults to `false`. |
**xAI-specific options** (when `provider: 'xai'`):
- `language` (String): Language code (e.g. `en`, `fr`). Enables text formatting when `format` is `true`.
- `format` (Boolean): When `true`, enables Inverse Text Normalization (numbers/currency to written form). Requires `language`.
- `diarize` (Boolean): When `true`, words include a `speaker` field identifying the detected speaker.
- `multichannel` (Boolean): When `true`, transcribes each audio channel independently.
- `channels` (Number): Number of audio channels (2–8). Required for multichannel raw audio.
- `audio_format` (String): Format hint for raw/headerless audio: `pcm`, `mulaw`, `alaw`.
- `sample_rate` (Number): Sample rate in Hz. Required for raw audio.
**Gemini-specific options** (when `provider: 'gemini'`):
- `language` (String): Language code (e.g. `en`) the audio is spoken in. The result echoes it as `language`. Without it, the result has no `language`.
- `timestamp_granularities` (Array): `['word']` adds a `words` array with each word's `start` and `end` in seconds. Only `word` entries are supported. Other entries fail with `bad_request`. When Gemini returns a word with an unusable timing or speaker, that word is left out of `words`, and `text` still has the full transcript.
- `diarize` (Boolean): When `true`, the result includes `words`. Words can include a `speaker` label such as `spk:0`.
Gemini rejects options it can't honor with `bad_request` instead of ignoring them. These are `prompt`, `temperature`, `logprobs: true`, `stream: true`, `chunking_strategy`, `known_speaker_names`, `extra_body`, and the xAI-specific options.
Gemini accepts WAV, MP3, AIFF, AAC, OGG, FLAC, MPEG, M4A, L16, Opus, A-law, mu-law and WebM audio, sent with an `audio/*` MIME type (`audio/wav`, `audio/mpeg`, `audio/webm`, and so on). Other types fail with `bad_request`, and the audio isn't converted. The audio can be at most 10 MiB, or 1 hour long, or 30 minutes long with word timestamps or `diarize`. Gemini enforces the duration limits itself, and the call fails when the audio is longer. A transcript is limited to 8,192 output tokens. A longer one fails with `502 upstream_failed`, so split long recordings into shorter files.
Before transcribing, Puter reserves the cost of the counted audio tokens plus the full 8,192 output tokens, then charges the tokens Gemini reports. A transcription Gemini answers is charged even when it fails, as Gemini bills it, including one cut off at the output limit. When Gemini reports no usable token counts, only the counted audio is charged.
On a Puter server that doesn't have Gemini set up, Gemini transcription fails with `503 service_unavailable` and isn't charged. Test mode still works there.
Some models accept additional options or handle these options differently. See [Model-specific features](#model-specific-features).
#### `testMode` (Boolean) (optional)
@@ -77,11 +59,49 @@ When `true`, skips the live API call and returns a static sample transcript so y
Returns a `Promise` that resolves to either:
- A string (when `response_format: "text"`), or
- An object of [`Speech2TxtResult`](/Objects/speech2txtresult) containing the transcription payload (including per-word timestamps and speakers on xAI and Gemini, depending on the provider and options). This is the default, including when you pass a bare `source` with no options. On Gemini it has `text`, `model`, `language` when you passed one, and `words` when you asked for word timestamps or `diarize`. Gemini returns no `duration`.
- An object of [`Speech2TxtResult`](/Objects/speech2txtresult) containing the transcription payload (which can include per-word timestamps and speakers, depending on the model and options). This is the default, including when you pass a bare `source` with no options.
## Model-specific features
### OpenAI models
OpenAI is the default provider, and its default model is `gpt-transcribe`. The `extra_body` option (`Object`) is forwarded verbatim to the OpenAI API for experimental flags.
### xAI (Grok) models
Use `provider: 'xai'`. Its model is `grok-voice-transcribe-2.0`.
| Option | Type | Description |
|--------|------|-------------|
| `language` | `String` | Language code (e.g. `en`, `fr`). Enables text formatting when `format` is `true`. |
| `format` | `Boolean` | When `true`, enables Inverse Text Normalization (numbers/currency to written form). Requires `language`. |
| `diarize` | `Boolean` | When `true`, words include a `speaker` field identifying the detected speaker. |
| `multichannel` | `Boolean` | When `true`, transcribes each audio channel independently. |
| `channels` | `Number` | Number of audio channels (2–8). Required for multichannel raw audio. |
| `audio_format` | `String` | Format hint for raw/headerless audio: `pcm`, `mulaw`, `alaw`. |
| `sample_rate` | `Number` | Sample rate in Hz. Required for raw audio. |
### Gemini models
Use `provider: 'gemini'`. Its only model is `gemini-3.5-transcribe`.
| Option | Type | Description |
|--------|------|-------------|
| `language` | `String` | Language code (e.g. `en`) the audio is spoken in. The result echoes it as `language`. Without it, the result has no `language`. |
| `timestamp_granularities` | `Array` | Passing `['word']` adds a `words` array with each word's `start` and `end` in seconds. Only `word` entries are supported. Other entries fail with `bad_request`. When Gemini returns a word with an unusable timing or speaker, that word is left out of `words`, and `text` still has the full transcript. |
| `diarize` | `Boolean` | When `true`, the result includes `words`. Words can include a `speaker` label such as `spk:0`. |
Gemini rejects options it can't honor with `bad_request` instead of ignoring them. These are `prompt`, `temperature`, `logprobs: true`, `stream: true`, `chunking_strategy`, `known_speaker_names`, `extra_body`, and the xAI-specific options.
Gemini accepts WAV, MP3, AIFF, AAC, OGG, FLAC, MPEG, M4A, L16, Opus, A-law, mu-law and WebM audio, sent with an `audio/*` MIME type (`audio/wav`, `audio/mpeg`, `audio/webm`, and so on). Other types fail with `bad_request`, and the audio isn't converted. The audio can be at most 10 MiB, or 1 hour long, or 30 minutes long with word timestamps or `diarize`. Gemini enforces the duration limits itself, and the call fails when the audio is longer. A transcript is limited to 8,192 output tokens. A longer one fails with `502 upstream_failed`, so split long recordings into shorter files.
The result has `text`, `model`, `language` when you passed one, and `words` when you asked for word timestamps or `diarize`. Gemini returns no `duration`.
Before transcribing, Puter reserves the cost of the counted audio tokens plus the full 8,192 output tokens, then charges the tokens Gemini reports. A transcription Gemini answers is charged even when it fails, as Gemini bills it, including one cut off at the output limit. When Gemini reports no usable token counts, only the counted audio is charged.
## Examples
<strong class="example-title">Transcribe a file</strong>
### Transcribe a file
```html;ai-speech2txt
<html>
@@ -97,7 +117,7 @@ Returns a `Promise` that resolves to either:
</html>
```
<strong class="example-title">Transcribe with xAI (Grok)</strong>
### Transcribe with xAI (Grok)
```html;ai-speech2txt-xai
<html>
@@ -124,7 +144,7 @@ Returns a `Promise` that resolves to either:
</html>
```
<strong class="example-title">Transcribe with Gemini, with word timestamps and speakers</strong>
### Transcribe with Gemini, with word timestamps and speakers
```html;ai-speech2txt-gemini
<html>
@@ -148,7 +168,7 @@ Returns a `Promise` that resolves to either:
</html>
```
<strong class="example-title">Use test mode during development</strong>
### Use test mode during development
```html
<html>
+192 -227
View File
@@ -29,233 +29,21 @@ A boolean indicating whether you want to use the test API. Defaults to `false`.
Additional settings for the generation request. Available options depend on the provider.
| Option | Type | Description |
|--------|------|-------------|
| -------- | ------ | ------------- |
| `prompt` | `String` | Text description for the image generation |
| `provider` | `String` | Optional provider: `'openai'`, `'gemini'`, `'cloudflare'`, `'xai'`, `'replicate'`, `'byteplus'`, or `'kling'` (`'together'` is still recognized, but every Together route is currently excluded). Full `*-image-generation` IDs also work. This is a preference: if it does not offer the requested model, Puter selects a provider that does. Selecting a route excluded by data policy returns `bad_request` instead. Inferred from `model` when omitted; use a provider-prefixed model ID to select an exact provider. |
| `model` | `String` | Image model ID. See the provider defaults and model discovery below. Lookups are case-insensitive; provider-prefixed IDs select that provider. |
| `test_mode` | `Boolean` | When `true`, returns a sample image without using credits |
| `puter_output_path` | `String` | When set, the generated image is automatically saved to this path on the Puter filesystem. Relative paths are resolved against the app's data directory (or `~/` outside an app). The caller must have write permission to the destination |
| `input_images` | `Array<String \| Blob>` | Input image(s) for image-to-image. Works with every provider (see below). |
| `model` | `String` | Image model ID. Defaults to `gpt-image-2.6-flare`. Lookups are case-insensitive; provider-prefixed IDs, such as `replicate:leonardoai/lucid-origin`, select that provider. See the [AI model directory](https://developer.puter.com/ai/models/) for the included models and price units. |
| `ratio` | `Object` | Aspect ratio as `{ w, h }` (e.g., `{ w: 16, h: 9 }`). See [Output dimensions](#output-dimensions). |
| `aspect_ratio` | `String` | Aspect ratio (e.g., `'16:9'`). See [Output dimensions](#output-dimensions). |
| `width` | `Number` | Output width in pixels. Set it together with `height`. See [Output dimensions](#output-dimensions). |
| `height` | `Number` | Output height in pixels. Set it together with `width`. See [Output dimensions](#output-dimensions). |
| `input_images` | `Array<String \| Blob>` | Input image(s) for image-to-image. Works with every provider. See [Input images (image-to-image)](#input-images-image-to-image). |
| `input_image` | `String \| Blob` | Single-image shorthand for `input_images`. |
| `input_image_mime_type` | `String` | MIME type of the input image(s), e.g. `'image/png'`. Used as a fallback when the type cannot be detected from the input. Pass it when supplying raw base64 without a data-URI prefix. |
| `puter_output_path` | `String` | When set, the generated image is automatically saved to this path on the Puter filesystem. Relative paths are resolved against the app's data directory (or `~/` outside an app). The caller must have write permission to the destination. See [Saving to Puter filesystem](#saving-to-puter-filesystem). |
| `provider` | `String` | Optional provider: `'openai'`, `'gemini'`, `'cloudflare'`, `'xai'`, `'replicate'`, `'byteplus'`, or `'kling'`. This is a preference: if it does not offer the requested model, Puter selects a provider that does. Selecting a route excluded by data policy returns `bad_request` instead. Inferred from `model` when omitted; use a provider-prefixed model ID to select an exact provider. |
| `test_mode` | `Boolean` | When `true`, returns a sample image without using credits |
#### Providers and model discovery
When `model` is supplied, Puter selects its provider. Use a provider-prefixed ID such as `workers-ai:leonardo/lucid-origin` or `replicate:leonardoai/lucid-origin` to choose between providers offering similar models. A `provider` or legacy `driver` hint prefers that provider when it offers the model. Otherwise, a known model selects its own provider. A hint selecting a route excluded by data policy fails with `bad_request` instead of falling back. The generic `ai-image` hint is case-insensitive. The legacy `driver` option accepts full driver IDs such as `xai-image-generation`; `service` does not select an image provider.
Without a model, these provider defaults apply:
| Provider | Default model |
|----------|---------------|
| OpenAI | `gpt-image-2` |
| Gemini | `gemini-3.1-flash-image` |
| Cloudflare | `@cf/black-forest-labs/flux-1-schnell` |
| xAI | `grok-imagine-image` |
| Replicate | `black-forest-labs/flux-schnell` |
| BytePlus | `seedream-5-0-lite-260128` |
| Kling | `kling-v3` |
When neither is supplied, OpenAI's `gpt-image-2` is used.
Discover the image catalog, including model IDs, aliases, provider names, and pricing metadata:
```js
const { result: models } = await puter.drivers.call(
'puter-image-generation', 'models', {}
);
console.table(models.map(({ id, provider }) => ({ id, provider })));
```
See the [AI model directory](https://developer.puter.com/ai/models/) for the included models and price units.
The [`puter.ai.listModels()`](/AI/listModels/) function lists chat models, not image models.
#### Output dimensions
Use `ratio: { w: 16, h: 9 }`, `aspect_ratio: '16:9'`, or a pair of pixel dimensions (`width`, `height`). `ratio` takes precedence, followed by `width`/`height`, then `aspect_ratio`. `aspect_ratio` always denotes a shape. Legacy dimension pairs with both sides at most 32 also denote shapes. BytePlus retains its pixel-threshold rule described below. Invalid, non-positive, or non-finite dimensions fail with `bad_request`.
Supported shapes and sizes vary by model. Gemini and BytePlus choose the closest supported aspect ratio. OpenAI scales any aspect hint into its pixel budget. Cloudflare expands aspect hints to about one megapixel, then scales or clamps dimensions to the per-side limits below. Cloudflare Schnell always generates 1024×1024 images.
#### Input images (image-to-image)
To pass input images for image-to-image, use `input_images`, which works with every provider. For a single image, `input_image` is a shorthand. Each entry can be a **public URL**, a **data-URI**, **raw base64**, the **path of a file** in the user's Puter filesystem, or a **`Blob` or `File`**. Every form works with every provider.
Puter reads a Puter file with the caller's access, so a file the caller can't read fails with `access_denied` before generating. The file must be a PNG, JPEG, WebP or GIF image, and the Puter files one request names can be up to 32 MiB together. `maskImage` takes the same forms.
puter.js uploads a `Blob`, a `File` or a data-URI over 1 MiB to a temporary file and sends its path instead of the bytes. The file goes in `~/AppData/<appID>/.tmp/` when called from an app, or `~/.tmp/` otherwise, and counts against the user's storage until puter.js deletes it after the call, whether the call succeeds or fails. When an image can't be uploaded, for example because the user's storage is full, puter.js sends it inline instead.
Raw base64 carries no MIME type of its own. When it cannot be detected from the bytes, set `input_image_mime_type`, such as `'image/png'`. Gemini rejects the request without it, and OpenAI, xAI, Cloudflare, BytePlus, and Replicate use it to label the upload.
| Provider | Multiple images? | Accepted input forms |
|----------|------------------|----------------------|
| OpenAI `gpt-image-*` | Yes | URL, base64 / data-URI |
| Gemini | Yes | URL, base64 / data-URI |
| Replicate | Up to 10 (400 if exceeded); model-dependent | URL, base64 / data-URI |
| xAI `grok-imagine-*` | Up to 5 (400 if exceeded) | URL, base64 / data-URI |
| Cloudflare FLUX.2 | Single only (400 if more than one) | URL, base64 / data-URI. Inpainting also accepts one reference; SDXL, Schnell, and Leonardo models are text-to-image only and reject references. |
| BytePlus | Up to 10 for Seedream 5.0 Pro; 14 for other Seedream models | URL, base64 / data-URI |
| Kling | Up to 10 for `kling-v3-omni` and `kling-image-o1`; one for `kling-v3` and `kling-v2-1` | URL, base64 / data-URI. JPEG or PNG, up to 10 MB, at least 300 pixels per side |
#### OpenAI Options
Available when `provider: 'openai'` or inferred from model (`gpt-image-2.5-sunburst`, `gpt-image-2.5-flare`, `gpt-image-2`):
| Option | Type | Description |
|--------|------|-------------|
| `model` | `String` | Image model to use. Available: `'gpt-image-2.5-sunburst'`, `'gpt-image-2.5-flare'`, `'gpt-image-2'` (default). |
| `quality` | `String` | Image quality: `'high'`, `'medium'`, `'low'` (default: `'low'`); `gpt-image-2` also accepts `'auto'`, and the `gpt-image-2.5-*` models also accept `'xhigh'`, `'max'`, and `'auto'`. Case-insensitive; unrecognized values fall back to `'low'`. |
| `ratio` | `Object` | Aspect ratio with `w` and `h` properties. All GPT Image models accept dynamic sizes subject to provider limits (16-pixel steps, 3:1 maximum ratio, 3840-pixel edge cap) |
| `input_image` | `String` | An input image for image-to-image editing, as a URL or base64/data-URI. |
| `input_images` | `Array<String>` | Multiple input images (URL or base64/data-URI) for image-to-image editing. |
For more details, see the [OpenAI API reference](https://platform.openai.com/docs/api-reference/images/create).
#### Gemini Options
Available when `provider: 'gemini'` or inferred from model:
| Option | Type | Description |
|--------|------|-------------|
| `model` | `String` | `'gemini-3.1-flash-image'` (default), `'gemini-nano-banana-2.1'` (Nano Banana 2.1), `'gemini-3.1-flash-lite-image'`, or `'gemini-3-pro-image'`. |
| `ratio` | `Object` | Aspect ratio as `{ w, h }` (e.g., `{ w: 16, h: 9 }`). |
| `quality` | `String` | Nano Banana 2.1 and 3 Pro: `'1K'`, `'2K'`, `'4K'`; 3.1 Flash: `'512'`, `'1K'`, `'2K'`, `'4K'`; 3.1 Flash Lite: `'1K'`. Case-insensitive; a tier the model does not offer returns `bad_request` before any credits are checked. Defaults to the first supported tier: `'512'` on 3.1 Flash, `'1K'` on Nano Banana 2.1, 3 Pro, and 3.1 Flash Lite. |
| `input_images` | `Array<String>` | Input images for image-to-image, as URLs or base64/data-URIs. |
#### xAI (Grok) Options
Available when `provider: 'xai'` or inferred from model (`grok-imagine-image`, alias `grok-image`):
| Option | Type | Description |
|--------|------|-------------|
| `model` | `String` | Image model to use. Available: `'grok-imagine-image'` (default), `'grok-imagine-image-2.0'` |
| `prompt` | `String` | Text prompt for the image (or the edit instruction when input images are supplied). |
| `resolution` | `String` | Output resolution: `'1k'` (default) or `'2k'`. |
| `quality` | `String` | On 2.0 (case-insensitive): `'low'`, `'medium'`, or `'auto'` (default; low for generation, medium for edits). On older models, `'1k'`/`'2k'` remains a resolution alias. Explicit `resolution` takes precedence. |
| `input_image` | `String` | A public URL or base64-encoded (data-URI) input image for image-to-image editing. |
| `input_images` | `Array<String>` | Up to 5 input images (URLs or base64/data-URI) for multi-image editing, such as combining subjects, transferring styles, or composing scenes. |
#### Together Options
Together image routes are excluded because they require a third-party data-sharing opt-in. Existing provider IDs and the Together-only SDK options (`image_url`, `image_base64`, `mask_image_url`, `mask_image_base64`, `prompt_strength`, and `response_format` as `'base64'`/`'url'`) remain recognized for compatibility but have no effect, and selecting an excluded route rejects with `bad_request`, including in test mode.
#### Cloudflare Options
Use `provider: 'cloudflare'`, a `@cf/...` model ID, or its `workers-ai:...` alias. Available models are FLUX.1 Schnell, FLUX.2 Dev, FLUX.2 Klein 4B/9B, Lucid Origin, Phoenix 1.0, SDXL Lightning, SDXL Base 1.0, and Stable Diffusion 1.5 Inpainting.
| Option | Type | Description |
|--------|------|-------------|
| `steps` | `Number` | Schnell: 1–8, default 4. Lucid Origin: 1–40, default 25. Phoenix and FLUX.2 Dev: 1–50, default 25. Klein models always use 4 steps. SDXL and Inpainting: 1–20, default 20. |
| `width`, `height` | `Number` | Pixel dimensions; ignored by Schnell. Clamped to 256–1920 per side for FLUX.2, 64–2500 for Lucid Origin, 64–2048 for Phoenix, and 256–2048 for SDXL and Inpainting. Aspect hints are scaled to fit; shapes wider or taller than the bounds permit are clamped. |
| `seed` | `Number` | Reproducibility seed on models other than Schnell. |
| `guidance` | `Number` | Prompt guidance on models other than Schnell. |
| `negative_prompt` | `String` | Content to exclude (Phoenix, SDXL, and Inpainting). |
| `input_image`, `input_images` | `String`, `Array<String>` | One reference image for FLUX.2 and Inpainting models. Inpainting requires an image; SDXL models reject one. |
| `maskImage` | `String \| Blob` | Mask for Inpainting, in any [input image](#input-images-image-to-image-) form; requires an input image. |
| `strength` | `Number` | Transformation strength for Inpainting image editing. |
Output encoding is selected by the model; `response_format` does not change Cloudflare output.
#### BytePlus Options
Use `provider: 'byteplus'` or a Seedream model ID. Available models are `dola-seedream-5-0-pro-260628`, `seedream-5-0-lite-260128`, `seedream-4-5-251128`, and `seedream-4-0-250828`.
| Option | Type | Description |
|--------|------|-------------|
| `quality` | `String` | Output tier, case-insensitive. Pro: `1K`, `1.5K`, `2K`; Lite: `2K`, `3K`, `4K`; 4.5: `2K`, `4K`; 4.0: `1K`, `2K`, `4K`. Default `2K`. Legacy aliases: `low` → `1K`, `medium` → `1.5K`, `high`/`hd` → `2K`. Unsupported tiers move to the next supported tier, or the largest supported tier if no larger one exists. |
| `ratio` | `Object` | Aspect ratio or explicit pixel dimensions. The closest supported aspect ratio maps to the model's output tier. |
| `input_image`, `input_images` | `String`, `Array<String>` | Reference images; up to 10 for Pro, 14 for other models. |
For compatibility, a dimension pair containing fewer than 921,600 total pixels is treated as an aspect-ratio hint and mapped to the selected tier. Larger pairs are explicit sizes and must satisfy both the pixel-count and aspect-ratio constraints in [Rate Limits and Quotas](/rate-limits-and-quotas#image-generation). See the [ModelArk image API reference](https://docs.byteplus.com/en/docs/ModelArk/1541523).
#### Kling Options
Use `provider: 'kling'` or a Kling model ID. Each request generates one image, billed per image:
| Model | Alias | Price per image | `quality` | Input images |
|-------|-------|-----------------|-----------|--------------|
| `kling-v3` (default) | `kling-image-3.0` | $0.028 | `1k` (default), `2k` | One |
| `kling-v3-omni` | `kling-image-3.0-omni` | $0.028, or $0.056 at `4k` | `1k` (default), `2k`, `4k` | Up to 10 |
| `kling-image-o1` | | $0.028 | `1k` (default), `2k` | Up to 10 |
| `kling-v2-1` | `kling-image-2.1` | $0.014, or $0.028 with an input image | `1k` (default), `2k` | One |
| Option | Type | Description |
|--------|------|-------------|
| `quality` | `String` | Output resolution from the table above, case-insensitive. `low` and `medium` select `1k`, `high` and `hd` select `2k`. Any other value selects `1k` |
| `ratio` | `Object` | Aspect ratio. The closest of 16:9, 9:16, 1:1, 4:3, 3:4, 3:2, 2:3 and 21:9 is used. Defaults to 1:1, or to a ratio Kling picks from the input images on `kling-v3-omni` and `kling-image-o1` |
| `input_image`, `input_images` | `String`, `Array<String>` | Reference images. In the prompt, `<<<image_1>>>` refers to the first image on `kling-v3-omni` and `kling-image-o1` |
| `negative_prompt` | `String` | What to keep out of the image, on `kling-v3` and `kling-v2-1` without an input image |
Kling returns a hosted URL that stays available for 30 days. See the [Kling image API reference](https://kling.ai/document-api/api/image/3-0-omni/image-generation).
#### Replicate Options
Available when `provider: 'replicate'` or inferred from model:
##### Common options
| Option | Type | Description |
|--------|------|-------------|
| `model` | `String` | Model id (e.g. `'black-forest-labs/flux-schnell'`, `'leonardoai/lucid-origin'`). |
| `ratio` | `Object` | Aspect ratio as `{ w, h }` (e.g., `{ w: 16, h: 9 }`). |
| `input_image` | `String` | Input image for image-to-image generation, as a URL or base64/data-URI. |
| `input_images` | `Array<String>` | Up to 10 input images (URL or base64/data-URI) for multi-image generation; more return `bad_request` before anything is fetched. Each fetched reference is capped at 30 MB; a larger one fails with `bad_request` before the request is sent. |
##### Per-model options
Available options depend on the model. The expanded catalog includes FLUX variants, Nano Banana, Seedream, Ideogram, Recraft, Qwen, Hunyuan, Wan, Riverflow, and community models. Use a `replicate:`-prefixed ID to select Replicate explicitly.
For the additional models, `quality` (or the model's own `resolution` field, where it has one) selects a native quality or resolution tier, and `ratio`/`width`/`height` select supported dimensions. `steps`, `guidance`, `negative_prompt`, and `strength` map to the corresponding native controls when supported. `maskImage` supplies an inpainting mask. Explicit pixel controls round to multiples of 8 and obey model bounds, defaulting to 64–4096 per side when the schema omits a bound. Pass additional native options in `providerOptions`; native snake_case and camelCase spellings work. Common options take precedence, unsupported fields are omitted, and invalid values return `bad_request`. Image counts are fixed at one where the model exposes a count option. Models returning SVG still use the same image return shape.
Specialized models require their inputs: control/fill/redux models need a reference image; `any-comfyui-workflow` needs `providerOptions.workflowJson` containing a ComfyUI API workflow that produces images. A workflow controls its own graph and outputs. MiniMax Image 01 uses a human-face reference as its subject.
```js
const image = await puter.ai.txt2img("A colorful ceramic cup", {
model: "replicate:google/nano-banana-2",
quality: "2K",
ratio: { w: 16, h: 9 },
providerOptions: { outputFormat: "png" }
});
document.body.append(image);
```
Billing follows the selected model and tier: per image, per input/output megapixel, or per second of reported prediction runtime. Runtime-priced entries use a 60-second estimate for the initial credit check and record actual runtime after generation; runtime is billed up to the 10-minute prediction deadline, so a long run can exceed the estimate. See the [AI model directory](https://developer.puter.com/ai/models/).
The original Replicate entries also support these options:
| Option | Type | Models | Description |
|--------|------|--------|-------------|
| `seed` | `Number` | most models | Random seed for reproducible generation. |
| `steps` | `Number` | `flux-schnell` | Number of inference steps, 1–4. |
| `guidance` | `Number` | `flux-2-klein-9b-base` | Guidance scale. |
| `go_fast` | `Boolean` | `flux-2-dev` | Use optimized fast mode. Defaults to `true` for `flux-2-dev`; affects pricing. |
| `output_quality` | `Number` | flux family | Output quality (0–100). |
| `output_megapixels` | `String` | `flux-2-pro`, `flux-2-klein-*`, `flux-schnell` | Output size in megapixels, from the model's own list: `flux-2-pro` `'0.5'`, `'1'`, `'2'`, `'4'`; Klein `'0.25'`, `'0.5'`, `'1'`, `'2'`, `'4'`; `flux-schnell` `'0.25'` or `'1'`. Not accepted by `flux-2-dev` or `flux-1.1-pro`, where it is ignored. |
| `disable_safety_checker` | `Boolean` | flux-2-dev / klein / flux-schnell | If `true`, disables the safety checker. |
| `safety_tolerance` | `Number` | `flux-2-pro`, `flux-1.1-pro` | Safety tolerance level. |
| `prompt_upsampling` | `Boolean` | `flux-1.1-pro` | Enable prompt upsampling. |
| `response_format` | `String` | most models | Output format (e.g. `'webp'`, `'jpg'`, `'png'`). |
| `generation_mode` | `String` | Leonardo (`lucid-origin`) | Generation tier, which affects pricing. `'standard'`/`'ultra'` (lucid-origin). |
| `style` | `String` | Leonardo | Stylistic preset. |
| `contrast` | `String` | Leonardo | Contrast preset. |
| `prompt_enhance` | `Boolean` | Leonardo | Server-side prompt enhancement. |
If you abort a Replicate request, Puter cancels the prediction. A generation that completes before it's cancelled is still metered. Predictions have a 10-minute [upstream deadline](https://replicate.com/docs/topics/predictions/create-a-prediction#prediction-deadlines), after which the call fails with `upstream_timeout` (504). A call starts at most one prediction, since a failed creation request isn't retried. If checking on a running prediction fails, the call fails with `upstream_failed` (502), and the prediction isn't cancelled or metered.
For more details, see the [Replicate API reference](https://replicate.com/docs) and each model's schema page on Replicate.
Any properties not set fall back to provider defaults.
#### Saving to Puter filesystem
Pass `puter_output_path` to persist the generated image directly on the Puter filesystem. Relative paths are resolved against `~/AppData/<appID>/` when called from an app, or `~/` otherwise:
```js
puter.ai.txt2img("A sunset over the mountains", {
puter_output_path: "images/sunset.png" // saved to ~/AppData/<appID>/images/sunset.png
});
```
Absolute paths (`/username/Pictures/sunset.png`) and home-relative paths (`~/Pictures/sunset.png`) are sent as-is. Write permission to the destination is enforced server-side.
Some models accept additional options or handle these options differently. See [Model-specific features](#model-specific-features).
## Return value
@@ -274,9 +62,186 @@ A rejection carries the error body as the backend sent it: `{ message, code }`,
Other `upstream_*` codes mean the provider rejected the request or was unavailable; the `message` carries the provider's reason.
## Output dimensions
Supported shapes and sizes vary by model.
When you pass more than one dimension option, `ratio` takes precedence, followed by `width` and `height`, then `aspect_ratio`.
Each option sets either an aspect ratio or a size in pixels:
- The `aspect_ratio` option is always an aspect ratio, such as `'16:9'`.
- The `ratio` option, and the `width` and `height` pair, set an aspect ratio when both values are 32 or less, such as `{ w: 16, h: 9 }`. Larger values set a size in pixels, such as `{ w: 1024, h: 576 }`.
Invalid, non-positive, or non-finite dimensions fail with `bad_request`.
## Input images (image-to-image)
To pass input images for image-to-image, use `input_images`, which works with every provider. For a single image, `input_image` is a shorthand. Each entry can be a **public URL**, a **data-URI**, **raw base64**, the **path of a file** in the user's Puter filesystem, or a **`Blob` or `File`**. Every form works with every provider.
Puter reads a Puter file with the caller's access, so a file the caller can't read fails with `access_denied` before generating. The file must be a PNG, JPEG, WebP or GIF image, and the Puter files one request names can be up to 32 MiB together. The `maskImage` option takes the same forms.
puter.js uploads a `Blob`, a `File` or a data-URI over 1 MiB to a temporary file and sends its path instead of the bytes. The file goes in `~/AppData/<appID>/.tmp/` when called from an app, or `~/.tmp/` otherwise, and counts against the user's storage until puter.js deletes it after the call, whether the call succeeds or fails. When an image can't be uploaded, for example because the user's storage is full, puter.js sends it inline instead.
Raw base64 carries no MIME type of its own. When it cannot be detected from the bytes, set `input_image_mime_type`, such as `'image/png'`. Gemini rejects the request without it, and OpenAI, xAI, Cloudflare, BytePlus, and Replicate use it to label the upload.
## Saving to Puter filesystem
Pass `puter_output_path` to persist the generated image directly on the Puter filesystem. Relative paths are resolved against `~/AppData/<appID>/` when called from an app, or `~/` otherwise:
```js
puter.ai.txt2img("A sunset over the mountains", {
puter_output_path: "images/sunset.png" // saved to ~/AppData/<appID>/images/sunset.png
});
```
Absolute paths (`/username/Pictures/sunset.png`) and home-relative paths (`~/Pictures/sunset.png`) are sent as-is. Write permission to the destination is enforced server-side.
## Model-specific features
### OpenAI models
Available when `provider: 'openai'` or inferred from model (`gpt-image-2.6-sunburst`, `gpt-image-2.6-flare`, `gpt-image-2.5-sunburst`, `gpt-image-2.5-flare`, `gpt-image-2`):
| Option | Type | Description |
| -------- | ------ | ------------- |
| `model` | `String` | Image model to use. Available: `'gpt-image-2.6-flare'` (default), `'gpt-image-2.6-sunburst'`, `'gpt-image-2.5-sunburst'`, `'gpt-image-2.5-flare'`, `'gpt-image-2'`. |
| `quality` | `String` | Image quality: `'high'`, `'medium'`, `'low'` (default: `'low'`); `gpt-image-2` also accepts `'auto'`, and the `gpt-image-2.5-*` and `gpt-image-2.6-*` models also accept `'xhigh'`, `'max'`, and `'auto'`. Case-insensitive; unrecognized values fall back to `'low'`. |
| `ratio` | `Object` | Aspect ratio with `w` and `h` properties. All GPT Image models accept dynamic sizes subject to provider limits (16-pixel steps, 3:1 maximum ratio, 3840-pixel edge cap). When you pass an aspect ratio, OpenAI scales it up to a size within its pixel-count limits. |
For more details, see the [OpenAI API reference](https://platform.openai.com/docs/api-reference/images/create).
### Gemini models
Available when `provider: 'gemini'` or inferred from model:
| Option | Type | Description |
| -------- | ------ | ------------- |
| `model` | `String` | One of `'gemini-3.1-flash-image'` (default), `'gemini-nano-banana-2.1'` (Nano Banana 2.1), `'gemini-3.1-flash-lite-image'`, or `'gemini-3-pro-image'`. |
| `ratio` | `Object` | Aspect ratio as `{ w, h }` (e.g., `{ w: 16, h: 9 }`). Gemini chooses the closest supported aspect ratio. |
| `quality` | `String` | Nano Banana 2.1 and 3 Pro: `'1K'`, `'2K'`, `'4K'`; 3.1 Flash: `'512'`, `'1K'`, `'2K'`, `'4K'`; 3.1 Flash Lite: `'1K'`. Case-insensitive; a tier the model does not offer returns `bad_request` before any credits are checked. Defaults to the first supported tier: `'512'` on 3.1 Flash, `'1K'` on Nano Banana 2.1, 3 Pro, and 3.1 Flash Lite. |
### xAI (Grok) models
Available when `provider: 'xai'` or inferred from model (`grok-imagine-image`, alias `grok-image`):
| Option | Type | Description |
| -------- | ------ | ------------- |
| `model` | `String` | Image model to use. Available: `'grok-imagine-image'` (default), `'grok-imagine-image-2.0'` |
| `prompt` | `String` | Text prompt for the image (or the edit instruction when input images are supplied). |
| `resolution` | `String` | Output resolution: `'1k'` (default) or `'2k'`. |
| `quality` | `String` | On 2.0 (case-insensitive): `'low'`, `'medium'`, or `'auto'` (default; low for generation, medium for edits). On older models, `'1k'`/`'2k'` remains a resolution alias. Explicit `resolution` takes precedence. |
| `input_images` | `Array<String>` | Up to 5 input images (URLs or base64/data-URI) for multi-image editing, such as combining subjects, transferring styles, or composing scenes. More than 5 are rejected with HTTP 400. |
### Cloudflare models
Use `provider: 'cloudflare'`, a `@cf/...` model ID, or its `workers-ai:...` alias. Available models are FLUX.1 Schnell (`@cf/black-forest-labs/flux-1-schnell`, default), FLUX.2 Dev, FLUX.2 Klein 4B/9B, Lucid Origin, Phoenix 1.0, SDXL Lightning, SDXL Base 1.0, and Stable Diffusion 1.5 Inpainting.
| Option | Type | Description |
| -------- | ------ | ------------- |
| `steps` | `Number` | Schnell: 1–8, default 4. Lucid Origin: 1–40, default 25. Phoenix and FLUX.2 Dev: 1–50, default 25. Klein models always use 4 steps. SDXL and Inpainting: 1–20, default 20. |
| `width` | `Number` | Output width in pixels. Ignored by Schnell, which always generates 1024×1024 images. Clamped to 256–1920 per side for FLUX.2, 64–2500 for Lucid Origin, 64–2048 for Phoenix, and 256–2048 for SDXL and Inpainting. An aspect ratio is expanded to about one megapixel, then scaled to fit; shapes wider or taller than the bounds permit are clamped. |
| `height` | `Number` | Output height in pixels. Works the same way as `width`. |
| `seed` | `Number` | Reproducibility seed on models other than Schnell. |
| `guidance` | `Number` | Prompt guidance on models other than Schnell. |
| `negative_prompt` | `String` | Content to exclude (Phoenix, SDXL, and Inpainting). |
| `input_images` | `Array<String>` | One reference image for FLUX.2 and Inpainting models. More than one is rejected with HTTP 400. Inpainting requires an image; SDXL, Schnell, Lucid Origin and Phoenix reject one. |
| `maskImage` | `String \| Blob` | Mask for Inpainting, in any [input image](#input-images-image-to-image) form; requires an input image. |
| `strength` | `Number` | Transformation strength for Inpainting image editing. |
Output encoding is selected by the model; `response_format` does not change Cloudflare output.
### BytePlus models
Use `provider: 'byteplus'` or a Seedream model ID. Available models are `dola-seedream-5-0-pro-260628`, `seedream-5-0-lite-260128` (default), `seedream-4-5-251128`, and `seedream-4-0-250828`.
| Option | Type | Description |
| -------- | ------ | ------------- |
| `quality` | `String` | Output tier, case-insensitive. Pro: `1K`, `1.5K`, `2K`; Lite: `2K`, `3K`, `4K`; 4.5: `2K`, `4K`; 4.0: `1K`, `2K`, `4K`. Default `2K`. Legacy aliases: `low` → `1K`, `medium` → `1.5K`, `high`/`hd` → `2K`. Unsupported tiers move to the next supported tier, or the largest supported tier if no larger one exists. |
| `ratio` | `Object` | Aspect ratio or explicit pixel dimensions. The closest supported aspect ratio maps to the model's output tier. |
| `input_images` | `Array<String>` | Reference images; up to 10 for Pro, 14 for other models. |
BytePlus uses its own cutoff in place of the one in [Output dimensions](#output-dimensions). A `ratio` or `width` and `height` pair under 921,600 pixels in total sets an aspect ratio, and maps to the selected tier. Larger pairs set a size in pixels and must satisfy both the pixel-count and aspect-ratio constraints in [Rate Limits and Quotas](/rate-limits-and-quotas#image-generation). See the [ModelArk image API reference](https://docs.byteplus.com/en/docs/ModelArk/1541523).
### Kling models
Use `provider: 'kling'` or a Kling model ID. Each request generates one image, billed per image:
| Model | Alias | Price per image | `quality` | Input images |
| ------- | ------- | ----------------- | ----------- | -------------- |
| `kling-v3` (default) | `kling-image-3.0` | $0.028 | `1k` (default), `2k` | One |
| `kling-v3-omni` | `kling-image-3.0-omni` | $0.028, or $0.056 at `4k` | `1k` (default), `2k`, `4k` | Up to 10 |
| `kling-image-o1` | | $0.028 | `1k` (default), `2k` | Up to 10 |
| `kling-v2-1` | `kling-image-2.1` | $0.014, or $0.028 with an input image | `1k` (default), `2k` | One |
| Option | Type | Description |
| -------- | ------ | ------------- |
| `quality` | `String` | Output resolution from the table above, case-insensitive. The values `low` and `medium` select `1k`, and `high` and `hd` select `2k`. Any other value selects `1k` |
| `ratio` | `Object` | Aspect ratio. The closest of 16:9, 9:16, 1:1, 4:3, 3:4, 3:2, 2:3 and 21:9 is used. Defaults to 1:1, or to a ratio Kling picks from the input images on `kling-v3-omni` and `kling-image-o1` |
| `input_images` | `Array<String>` | Reference images, JPEG or PNG, up to 10 MB and at least 300 pixels per side. In the prompt, `<<<image_1>>>` refers to the first image on `kling-v3-omni` and `kling-image-o1` |
| `negative_prompt` | `String` | What to keep out of the image, on `kling-v3` and `kling-v2-1` without an input image |
Kling returns a hosted URL that stays available for 30 days. See the [Kling image API reference](https://kling.ai/document-api/api/image/3-0-omni/image-generation).
### Replicate models
Available when `provider: 'replicate'` or inferred from model:
#### Common options
| Option | Type | Description |
| -------- | ------ | ------------- |
| `model` | `String` | Model id (e.g. `'black-forest-labs/flux-schnell'`, `'leonardoai/lucid-origin'`). Defaults to `'black-forest-labs/flux-schnell'`. |
| `input_images` | `Array<String>` | Up to 10 input images (URL or base64/data-URI) for multi-image generation; more return `bad_request` before anything is fetched. Each fetched reference is capped at 30 MB; a larger one fails with `bad_request` before the request is sent. |
#### Per-model options
Available options depend on the model. The expanded catalog includes FLUX variants, Nano Banana, Seedream, Ideogram, Recraft, Qwen, Hunyuan, Wan, Riverflow, and community models. Use a `replicate:`-prefixed ID to select Replicate explicitly.
For the additional models, `quality` (or the model's own `resolution` field, where it has one) selects a native quality or resolution tier, and `ratio`/`width`/`height` select supported dimensions. The `steps`, `guidance`, `negative_prompt`, and `strength` options map to the corresponding native controls when supported. The `maskImage` option supplies an inpainting mask. Explicit pixel controls round to multiples of 8 and obey model bounds, defaulting to 64–4096 per side when the schema omits a bound. Pass additional native options in `providerOptions`; native snake_case and camelCase spellings work. Common options take precedence, unsupported fields are omitted, and invalid values return `bad_request`. Image counts are fixed at one where the model exposes a count option. Models returning SVG still use the same image return shape.
Specialized models require their inputs: control/fill/redux models need a reference image; `any-comfyui-workflow` needs `providerOptions.workflowJson` containing a ComfyUI API workflow that produces images. A workflow controls its own graph and outputs. MiniMax Image 01 uses a human-face reference as its subject.
```js
const image = await puter.ai.txt2img("A colorful ceramic cup", {
model: "replicate:google/nano-banana-2",
quality: "2K",
ratio: { w: 16, h: 9 },
providerOptions: { outputFormat: "png" }
});
document.body.append(image);
```
Billing follows the selected model and tier: per image, per input/output megapixel, or per second of reported prediction runtime. Runtime-priced entries use a 60-second estimate for the initial credit check and record actual runtime after generation; runtime is billed up to the 10-minute prediction deadline, so a long run can exceed the estimate. See the [AI model directory](https://developer.puter.com/ai/models/).
The original Replicate entries also support these options:
| Option | Type | Models | Description |
| -------- | ------ | -------- | ------------- |
| `seed` | `Number` | most models | Random seed for reproducible generation. |
| `steps` | `Number` | `flux-schnell` | Number of inference steps, 1–4. |
| `guidance` | `Number` | `flux-2-klein-9b-base` | Guidance scale. |
| `go_fast` | `Boolean` | `flux-2-dev` | Use optimized fast mode. Defaults to `true` for `flux-2-dev`; affects pricing. |
| `output_quality` | `Number` | flux family | Output quality (0–100). |
| `output_megapixels` | `String` | `flux-2-pro`, `flux-2-klein-*`, `flux-schnell` | Output size in megapixels, from the model's own list: `flux-2-pro` `'0.5'`, `'1'`, `'2'`, `'4'`; Klein `'0.25'`, `'0.5'`, `'1'`, `'2'`, `'4'`; `flux-schnell` `'0.25'` or `'1'`. Not accepted by `flux-2-dev` or `flux-1.1-pro`, where it is ignored. |
| `disable_safety_checker` | `Boolean` | flux-2-dev / klein / flux-schnell | If `true`, disables the safety checker. |
| `safety_tolerance` | `Number` | `flux-2-pro`, `flux-1.1-pro` | Safety tolerance level. |
| `prompt_upsampling` | `Boolean` | `flux-1.1-pro` | Enable prompt upsampling. |
| `response_format` | `String` | most models | Output format (e.g. `'webp'`, `'jpg'`, `'png'`). |
| `generation_mode` | `String` | Leonardo (`lucid-origin`) | Generation tier, which affects pricing. Either `'standard'` or `'ultra'` (lucid-origin). |
| `style` | `String` | Leonardo | Stylistic preset. |
| `contrast` | `String` | Leonardo | Contrast preset. |
| `prompt_enhance` | `Boolean` | Leonardo | Server-side prompt enhancement. |
If you abort a Replicate request, Puter cancels the prediction. A generation that completes before it's cancelled is still metered. Predictions have a 10-minute [upstream deadline](https://replicate.com/docs/topics/predictions/create-a-prediction#prediction-deadlines), after which the call fails with `upstream_timeout` (504). A call starts at most one prediction, since a failed creation request isn't retried. If checking on a running prediction fails, the call fails with `upstream_failed` (502), and the prediction isn't cancelled or metered.
For more details, see the [Replicate API reference](https://replicate.com/docs) and each model's schema page on Replicate.
Any properties not set fall back to provider defaults.
## Examples
<strong class="example-title">Generate an image of a cat using AI</strong>
### Generate an image of a cat using AI
```html;ai-txt2img
<html>
@@ -292,7 +257,7 @@ Other `upstream_*` codes mean the provider rejected the request or was unavailab
</html>
```
<strong class="example-title">Generate an image with specific model and quality</strong>
### Generate an image with specific model and quality
```html;ai-txt2img-options
<html>
@@ -301,7 +266,7 @@ Other `upstream_*` codes mean the provider rejected the request or was unavailab
<script>
// Generate an image of a cat playing piano using a specific model and quality set to low
puter.ai.txt2img("a cat playing the piano", {
model: "gpt-image-2",
model: "gpt-image-2.6-flare",
quality: "low"
}).then((image)=>{
document.body.appendChild(image);
@@ -311,7 +276,7 @@ Other `upstream_*` codes mean the provider rejected the request or was unavailab
</html>
```
<strong class="example-title">Generate an image with image-to-image generation</strong>
### Generate an image with image-to-image generation
```html;ai-txt2img-image-to-image
<html>
+29 -23
View File
@@ -32,12 +32,20 @@ Additional settings for the generation request. Available options depend on the
| Option | Type | Description |
|--------|------|-------------|
| `provider` | `String` | TTS provider to use. `'aws-polly'` (default), `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, `'speechify'`, `'kling'`. Common aliases (`'eleven'`, `'google'`, `'grok'`, `'polly'`, `'simba'`, …) are also accepted; anything else is rejected with a `bad_request` error |
| `model` | `String` | Model identifier (provider-specific) |
| `provider` | `String` | TTS provider to use. One of `'aws-polly'` (default), `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, `'speechify'`, or `'kling'`. Common aliases (`'eleven'`, `'google'`, `'grok'`, `'polly'`, `'simba'`, …) are also accepted; anything else is rejected with a `bad_request` error |
| `voice` | `String` | Voice ID used for synthesis (provider-specific) |
| `model` | `String` | Model identifier (provider-specific) |
| `test_mode` | `Boolean` | When `true`, returns a sample audio without using credits |
#### AWS Polly Options
Some models accept additional options or handle these options differently. See [Model-specific features](#model-specific-features).
## Return value
A `Promise` that resolves to an `HTMLAudioElement`. The element’s `src` points at a blob or remote URL containing the synthesized audio.
## Model-specific features
### AWS Polly models
Available when `provider: 'aws-polly'` (default):
@@ -48,7 +56,9 @@ Available when `provider: 'aws-polly'` (default):
| `language` | `String` | Language code. Defaults to `'en-US'`. See [supported languages](https://docs.aws.amazon.com/polly/latest/dg/supported-languages.html) |
| `ssml` | `Boolean` | When `true`, text is treated as SSML markup |
#### OpenAI Options
AWS Polly ignores `model`. Use `engine` to choose the synthesis engine.
### OpenAI models
Available when `provider: 'openai'`:
@@ -61,7 +71,7 @@ Available when `provider: 'openai'`:
For more details about each option, see the [OpenAI TTS API reference](https://platform.openai.com/docs/api-reference/audio/createSpeech).
#### ElevenLabs Options
### ElevenLabs models
Available when `provider: 'elevenlabs'`:
@@ -69,14 +79,14 @@ Available when `provider: 'elevenlabs'`:
|--------|------|-------------|
| `model` | `String` | TTS model. Available: `'eleven_multilingual_v2'` (default), `'eleven_v4'`, `'eleven_v4_turbo'`, `'eleven_v3'`, `'eleven_v3_conversational'`, `'eleven_flash_v2_5'`, `'eleven_flash_v2'` |
| `voice` | `String` | Voice ID, or a case-sensitive voice name from `listVoices({ provider: 'elevenlabs' })`. Premade voices also match their short name, so `'Bella'` selects `'Bella - Professional, Bright, Warm'`. Defaults to `'21m00Tcm4TlvDq8ikWAM'` |
| `output_format` | `String` | Output format as `codec_samplerate[_bitrate]`: `'mp3_22050_32'`, `'mp3_24000_48'`, `'mp3_44100_32'`, `'mp3_44100_64'`, `'mp3_44100_96'`, `'mp3_44100_128'` (default), `'mp3_44100_192'`, `'opus_48000_32'` through `'opus_48000_192'`, `'pcm_8000'` through `'pcm_48000'`, `'wav_8000'` through `'wav_48000'`, `'ulaw_8000'`, or `'alaw_8000'`. `response_format` is accepted as a fallback; the short names `'mp3'`, `'opus'`, `'pcm'`, and `'wav'` map to `'mp3_44100_128'`, `'opus_48000_128'`, `'pcm_24000'`, and `'wav_24000'`. See [ElevenLabs output formats](https://elevenlabs.io/docs/api-reference/text-to-speech/convert). |
| `output_format` | `String` | Output format as `codec_samplerate[_bitrate]`: `'mp3_22050_32'`, `'mp3_24000_48'`, `'mp3_44100_32'`, `'mp3_44100_64'`, `'mp3_44100_96'`, `'mp3_44100_128'` (default), `'mp3_44100_192'`, `'opus_48000_32'` through `'opus_48000_192'`, `'pcm_8000'` through `'pcm_48000'`, `'wav_8000'` through `'wav_48000'`, `'ulaw_8000'`, or `'alaw_8000'`. The `response_format` option is accepted as a fallback; the short names `'mp3'`, `'opus'`, `'pcm'`, and `'wav'` map to `'mp3_44100_128'`, `'opus_48000_128'`, `'pcm_24000'`, and `'wav_24000'`. See [ElevenLabs output formats](https://elevenlabs.io/docs/api-reference/text-to-speech/convert). |
| `voice_settings` | `Object` | Voice tuning options (stability, similarity boost, speed) |
Unknown voices and names matching multiple voices are rejected with HTTP 400 (`field_invalid`). Use a voice ID when names are ambiguous. An unsupported `output_format` is also rejected with HTTP 400 (`field_invalid`) before any credits are reserved.
For more details about each option, see the [ElevenLabs API reference](https://elevenlabs.io/docs/api-reference/text-to-speech).
#### Gemini Options
### Gemini models
Available when `provider: 'gemini'`:
@@ -92,7 +102,7 @@ A Gemini request is charged for the tokens Gemini reports, even when it fails or
For more details about Gemini TTS, see the [Google Gemini speech generation documentation](https://ai.google.dev/gemini-api/docs/speech-generation).
#### xAI (Grok) Options
### xAI (Grok) models
Available when `provider: 'xai'`:
@@ -106,7 +116,7 @@ Text supports inline speech tags like `[pause]`, `[laugh]` and wrapping tags lik
For more details, see the [xAI TTS documentation](https://x.ai/news/grok-stt-and-tts-apis).
#### Speechify Options
### Speechify models
Available when `provider: 'speechify'`:
@@ -118,7 +128,7 @@ Available when `provider: 'speechify'`:
For more details, see the [Speechify API documentation](https://docs.speechify.ai/).
#### Kling Options
### Kling models
Available when `provider: 'kling'`. Kling speaks English and Chinese, and each voice speaks one or both. The text can be up to 1,000 characters. Each request costs $0.007, whatever its length, and returns MP3 audio.
@@ -126,18 +136,14 @@ Available when `provider: 'kling'`. Kling speaks English and Chinese, and each v
|--------|------|-------------|
| `model` | `String` | TTS model. Available: `'kling-tts'` (default) |
| `voice` | `String` | Voice ID. Defaults to `'oversea_male1'`. [`txt2speech.listVoices({ provider: 'kling' })`](/AI/txt2speech.listVoices/) lists every voice, with its first language as `language` and all of its languages in `labels.languages` |
| `language` | `String` | `'en'` or `'zh'`. A region suffix such as `'en-US'` is ignored. Defaults to the voice's first language. A language the voice doesn't speak is rejected with `field_invalid` |
| `language` | `String` | Either `'en'` or `'zh'`. A region suffix such as `'en-US'` is ignored. Defaults to the voice's first language. A language the voice doesn't speak is rejected with `field_invalid` |
| `voice_settings` | `Object` | Only `speed` is read: a number from `0.8` to `2.0`. Defaults to `1.0` |
For more details, see the [Kling TTS API reference](https://kling.ai/document-api/api/video/avatar/text-to-speech).
## Return value
A `Promise` that resolves to an `HTMLAudioElement`. The element’s `src` points at a blob or remote URL containing the synthesized audio.
## Examples
<strong class="example-title">Convert text to speech (Shorthand)</strong>
### Convert text to speech (Shorthand)
```html;ai-txt2speech
<html>
@@ -155,7 +161,7 @@ A `Promise` that resolves to an `HTMLAudioElement`. The element’s `src` points
</html>
```
<strong class="example-title">Convert text to speech using options</strong>
### Convert text to speech using options
```html;ai-txt2speech-options
<html>
@@ -177,7 +183,7 @@ A `Promise` that resolves to an `HTMLAudioElement`. The element’s `src` points
</html>
```
<strong class="example-title">Use OpenAI voices</strong>
### Use OpenAI voices
```html;ai-txt2speech-openai
<html>
@@ -203,7 +209,7 @@ A `Promise` that resolves to an `HTMLAudioElement`. The element’s `src` points
</html>
```
<strong class="example-title">Use ElevenLabs voices</strong>
### Use ElevenLabs voices
```html;ai-txt2speech-elevenlabs
<html>
@@ -228,7 +234,7 @@ A `Promise` that resolves to an `HTMLAudioElement`. The element’s `src` points
</html>
```
<strong class="example-title">Use Gemini voices</strong>
### Use Gemini voices
```html;ai-txt2speech-gemini
<html>
@@ -253,7 +259,7 @@ A `Promise` that resolves to an `HTMLAudioElement`. The element’s `src` points
</html>
```
<strong class="example-title">Use xAI (Grok) voices</strong>
### Use xAI (Grok) voices
```html;ai-txt2speech-xai
<html>
@@ -277,7 +283,7 @@ A `Promise` that resolves to an `HTMLAudioElement`. The element’s `src` points
</html>
```
<strong class="example-title">Use Speechify voices</strong>
### Use Speechify voices
```html;ai-txt2speech-speechify
<html>
@@ -301,7 +307,7 @@ A `Promise` that resolves to an `HTMLAudioElement`. The element’s `src` points
</html>
```
<strong class="example-title">Compare different engines</strong>
### Compare different engines
```html;ai-txt2speech-engines
<html>
+123 -140
View File
@@ -4,7 +4,9 @@ description: Generate short video clips from text or reference images, or edit a
platforms: [websites, apps, nodejs, workers]
---
Given a prompt, generate a short video clip using AI. Puter routes the request to one of five upstream providers (Together AI, BytePlus Seedance, Google Gemini, Kling, MiniMax) based on the model you pick, waits for the clip to render, and resolves with a ready-to-play video. Every model can also start from an image you supply (image-to-video), and the same option names work across providers. Gemini Omni, FLUX 3 and the Kling Omni models can also edit a video you supply.
Given a prompt, generate a short video clip using AI.
Puter routes the request to one of five upstream providers (Together AI, BytePlus Seedance, Google Gemini, Kling, MiniMax) based on the model you pick, waits for the clip to render, and resolves with a ready-to-play video. Every model can also start from an image you supply (image-to-video), and the same option names work across providers. Some models can also edit a video you supply.
## Syntax
@@ -33,34 +35,67 @@ Additional settings for the generation request.
| Option | Type | Description |
|--------|------|-------------|
| `prompt` | `String` | Text description for the video generation |
| `model` | `String` | Video model to use. Defaults to `'togetherai:wan-ai/wan2.7-t2v'` (Wan 2.7 text-to-video). See [Choosing a model](#choosing-a-model) |
| `provider` | `String` | Pin the request to one provider: `'together-video-generation'`, `'byteplus-video-generation'`, `'gemini-video-generation'`, `'kling-video-generation'` or `'minimax-video-generation'`. Only needed when a model id exists on more than one provider |
| `seconds` | `Number` | Target clip length in seconds. Each model supports a fixed set of durations; an unsupported value falls back to the model default instead of failing. Gemini Omni has no length control and rejects this option. `duration` is an alias |
| `size` | `String` | Output size as `'WIDTHxHEIGHT'` (e.g. `'1280x720'`) on every provider. Models that work in resolution tiers take the tier of the shorter side plus the aspect ratio, and also accept the tier directly (`'720p'`). An unsupported value falls back to the model default, except on Gemini Omni, which rejects it. `resolution` is an alias |
| `width`, `height` | `Number` | Output size in pixels on Together AI models sized in pixels; the aspect ratio on Seedance, Kling, MiniMax H3 and Wan 2.7. Filled in from `size` when you leave them out |
| `model` | `String` | Video model to use. Defaults to `'togetherai:wan-ai/wan2.7-t2v'` (Wan 2.7 text-to-video). See [Model-specific features](#model-specific-features) for each provider's models |
| `seconds` | `Number` | Target clip length in seconds. Each model supports a fixed set of durations; an unsupported value falls back to the model default instead of failing. The `duration` option is an alias. See [Model-specific features](#model-specific-features) |
| `size` | `String` | Output size as `'WIDTHxHEIGHT'` (e.g. `'1280x720'`) on every provider. An unsupported value falls back to the model default. The `resolution` option is an alias. See [Model-specific features](#model-specific-features) |
| `width` | `Number` | Output width in pixels. Filled in from `size` when you leave it out. See [Model-specific features](#model-specific-features) |
| `height` | `Number` | Output height in pixels. Works the same way as `width` |
| `input_reference` | `String \| Blob` | Image the clip starts from (image-to-video), on every provider. See [Image inputs](#image-inputs) for the formats it takes |
| `last_frame` | `String \| Blob` | Image the clip ends on, same formats. Requires `input_reference` on Seedance, Kling and Hailuo 02; the MiniMax H3 models also take it alone |
| `reference_images` | `Array<String \| Blob>` | Images the model keeps consistent as subjects or style, same formats. Limits per provider are listed below; not combined with `input_reference` or `last_frame`, except on Kling |
| `last_frame` | `String \| Blob` | Image the clip ends on, same formats. See [Model-specific features](#model-specific-features) |
| `reference_images` | `Array<String \| Blob>` | Images the model keeps consistent as subjects or style, same formats. See [Model-specific features](#model-specific-features) |
| `inputVideo` | `String \| Blob` | Video to edit, with the prompt describing the change. Only Gemini Omni, FLUX 3, Kling 3.0 Omni and Kling O1 accept it. See [Video input](#video-input) |
| `negative_prompt` | `String` | Text describing what to avoid in the video (Together AI) |
| `seed` | `Number` | Random seed for reproducible results (Together AI and Seedance 1.x) |
| `generate_audio` | `Boolean` | Generate a soundtrack, on models that support audio (Seedance, Kling and Together AI models with audio) |
| `test_mode` | `Boolean` | When `true`, returns a sample video without using credits |
| `puter_output_path` | `String` | When set, the generated video is automatically saved to this path on the Puter filesystem. Relative paths are resolved against the app's data directory (or `~/` outside an app). The caller must have write permission to the destination |
| `provider` | `String` | Pin the request to one provider: `'together-video-generation'`, `'byteplus-video-generation'`, `'gemini-video-generation'`, `'kling-video-generation'` or `'minimax-video-generation'`. Only needed when a model id exists on more than one provider |
| `test_mode` | `Boolean` | When `true`, returns a sample video without using credits |
#### Choosing a model
Some models accept additional options or handle these options differently. See [Model-specific features](#model-specific-features).
Model ids are matched case-insensitively, and every model answers to a few spellings: the fully qualified id, the `org/model` form and the bare model name. `'togetherai:wan-ai/wan2.7-t2v'`, `'wan-ai/wan2.7-t2v'` and `'wan2.7-t2v'` all select the same model. When a bare name is served by more than one provider Puter picks the cheaper listing; pass `provider` or the fully qualified id to choose explicitly.
## Return value
When you pass no model, Puter uses Wan 2.7 text-to-video on Together AI (`wan2.7-t2v`).
A `Promise` that resolves to an `HTMLVideoElement` (in browsers) that you can append to the DOM straight away:
Google's Veo 3.1 preview models (`veo-3.1-generate-preview`, `veo-3.1-fast-generate-preview`, `veo-3.1-lite-generate-preview`) are no longer offered: Google deprecated them with a shutdown on October 22, 2026, and requests for those ids fail with `Model not found`. Veo 3.1 and Veo 3.1 Lite remain available through Together AI (see below).
- The `src` property is the clip's URL: an `https:` URL on the provider's storage (Together AI, Seedance, Kling or MiniMax), or a `data:video/mp4;base64,…` URI holding the whole clip (Gemini Omni). Provider URLs are temporary; keep a copy with `puter_output_path` if you need the clip later.
- The `controls` property is enabled and `preload` is `"metadata"`.
- The `data-source` attribute carries the same URL, and `data-mime-type` the MIME type when it is known (for example `video/mp4`).
- Calling `String(video)` returns the URL, so the element can be dropped into a template or passed to `fetch()`.
On Together AI and Seedance, `seconds` and `size` are normalized rather than rejected: a value the model does not offer is replaced by the model default, which is the first value listed in the tables below and also what you get when you leave the option out. Gemini Omni rejects a value it doesn't offer with `bad_request` instead (see [Gemini Omni options](#gemini-omni-options)).
In Node.js and Workers, where there is no DOM, the promise resolves to a plain object with the same `src`, `controls` and `preload` fields and the same `toString()`; the `data-*` attributes are not present. Use `fetch(video.src)` to read the bytes, or `puter_output_path` to have Puter store the file for you.
#### Image inputs
In test mode `src` is always the `https:` URL of Puter's sample clip.
`input_reference`, `last_frame` and `reference_images` take the same formats everywhere:
> **Note:** Each successful generation consumes the user's AI credits in accordance with the model, duration and resolution you request. See [Cost and clip length](#cost-and-clip-length) for how Puter fits a clip to the remaining balance.
## Errors
A rejection carries the error body exactly as the backend sent it, or `{ message, code }` for the checks the SDK runs before making the request. Every error has `message` and `code`; the other fields appear when they apply.
| Field | Meaning |
| --- | --- |
| `message` | Human-readable reason. The `error` field carries the same text for older clients. |
| `code` | Stable error code; see the table below. |
| `errorCode` | A more specific code alongside a general `code`. Today the only value is `moderation_flagged`. |
| `provider` | Which upstream handled the request: `together`, `byteplus`, `gemini`, `kling` or `minimax`. Present on errors raised while a job was running. |
| `upstreamCode` | The provider's own error code, when it gave one. |
| `upstreamStatus` | The HTTP status the provider returned, when it rejected the request before a job started. |
| Code | Meaning |
| --- | --- |
| `prompt_required` | Raised by the SDK before any request is made: the call had no prompt. |
| `bad_request` (without `errorCode`) | Puter rejected the request before contacting a provider: an unknown `model` (`Model not found: …`), an invalid combination of image inputs, an option Gemini Omni doesn't support, `inputVideo` on a model that can't take it, an image or video URL that could not be fetched, or a Puter file that isn't an accepted image or video format. The `message` field says which. Arrives as HTTP 400. |
| `input_too_large` | An image or video input is over its size limit. The limits are 5 MiB for an image on Gemini Omni, 32 MiB for a video, 1 MiB for a video sent as a `data:` URI, and 32 MiB for the Puter file images of one request together. Arrives as HTTP 413, before any credits are spent. |
| `access_denied` | The `puter_output_path` option points somewhere the caller may not write, or an image or video input names a Puter file the caller may not read. Arrives as HTTP 403, before any credits are spent. The `cannot_write_to_root` code (HTTP 400) is the same check for a path directly under `/`. |
| `upstream_timeout` | The provider did not finish the clip within the 14 minutes Puter waits for it, or stopped answering. Arrives as HTTP 504. The request itself was fine; retry it, ideally with a shorter clip or a faster model. |
| `errorCode: moderation_flagged` | The provider's content filter refused the prompt or removed the generated video. Arrives as HTTP 400, with `code: bad_request`. Change the prompt rather than retrying it as-is. |
| `upstream_bad_request` | The provider rejected the request itself, for example a duration the model does not support. Arrives as HTTP 400; `message` and `upstreamCode` carry the provider's reason. |
| `upstream_failed` | The provider accepted the request but generation failed on their side. Arrives as HTTP 502 and is safe to retry. |
| `insufficient_funds` | Your balance cannot cover even the shortest clip the model offers (or, for a per-clip Together AI model, the clip). Arrives as HTTP 402; `message` states the shortfall. |
Other `upstream_*` codes mean the provider rejected the request or was unavailable before a job started; `message` carries the provider's reason.
## Image inputs
The `input_reference`, `last_frame` and `reference_images` options take the same formats everywhere:
- a public image URL
- a `data:` URI, or raw base64 (treated as PNG)
@@ -71,17 +106,7 @@ Providers that need inline bytes fetch URLs server-side, so a URL works on every
puter.js uploads a `Blob`, a `File` or a `data:` URI over 1 MiB to a temporary file and sends its path instead of the bytes. The file goes in `~/AppData/<appID>/.tmp/` when called from an app, or `~/.tmp/` otherwise, and counts against the user's storage until puter.js deletes it after the call, whether the call succeeds or fails. When an image can't be uploaded, for example because the user's storage is full, puter.js sends it inline instead. A video that can't be uploaded fails the call with the upload's error.
What each provider does with image inputs:
| Provider | First frame | Last frame | Reference images |
|----------|-------------|------------|------------------|
| Together AI | Models with a `first` keyframe | Models with a `last` keyframe | Model-dependent (Seedance 2.5: up to 30) |
| BytePlus Seedance | Yes | Yes, with `input_reference` | Seedance 2.0: up to 9; Seedance 2.5: up to 30; cannot be combined with frames |
| Google Gemini | Yes, one PNG, JPEG or WebP image up to 5 MiB | No | No |
| Kling | Yes, JPEG or PNG | Yes, with `input_reference`, on the models listed in [Kling options](#kling-options) | Kling 3.0 Omni and O1: up to 7, or 4 with `inputVideo` |
| MiniMax | Yes, JPEG, PNG or WebP | Yes, on the H3 models, and with `input_reference` on Hailuo 02 | H3 models: up to 5 images in total with the frames, 2 on H3 Max; cannot be combined with frames |
#### Video input
## Video input
Four models can change a video you supply: Gemini Omni (`gemini-omni-1.1-flash`), FLUX 3 on Together AI (`black-forest-labs/flux-3`), and Kling 3.0 Omni (`kling-3.0-omni`) and Kling O1 (`kling-o1`). Pass the video as `inputVideo` and describe the change in the prompt, such as "Make this dog a dinosaur". The result is a new clip that keeps the scene and motion of your video.
@@ -92,22 +117,40 @@ const video = await puter.ai.txt2vid("Make this dog a dinosaur", {
});
```
The video can be a public URL, the path of a file in the user's Puter filesystem, a `Blob` or `File`, or a `data:` URI. Puter accepts a `data:` URI of up to 1 MiB, and puter.js uploads a `Blob`, a `File` or a larger `data:` URI to a temporary file, as described in [Image inputs](#image-inputs). Puter checks the size and format of a `data:` URI or Puter file before generating. Kling doesn't take a `data:` URI. The models differ in what they take and return:
The video can be a public URL, the path of a file in the user's Puter filesystem, a `Blob` or `File`, or a `data:` URI. Puter accepts a `data:` URI of up to 1 MiB, and puter.js uploads a `Blob`, a `File` or a larger `data:` URI to a temporary file, as described in [Image inputs](#image-inputs). Puter checks the size and format of a `data:` URI or Puter file before generating. The formats, sizes and lengths each model accepts are listed in its section under [Model-specific features](#model-specific-features).
| | Gemini Omni | FLUX 3 | Kling 3.0 Omni and O1 |
|---|---|---|---|
| Formats | MP4, QuickTime or WebM | MP4, QuickTime, WebM or Matroska | MP4 or QuickTime |
| Size | Up to 32 MiB | Up to 32 MiB for a Puter file. A URL is fetched by Together AI | Up to 200 MiB for a Puter file. A URL is fetched by Kling |
| Source length | Up to 10 seconds. A longer video fails with `upstream_bad_request` and isn't charged | Any | 3 to 15.5 seconds on Kling 3.0 Omni, 3 to 10 seconds on Kling O1. A video Kling can't decode fails with `upstream_bad_request` and isn't charged |
| Output length | The model decides. The prompt can also ask it to continue the clip past its end | `seconds`, from 5 to 20. Defaults to 6 | The length of the source. `seconds` is ignored |
| Output size | `size`, see [Gemini Omni options](#gemini-omni-options) | 1280x704 | `size`, see [Kling options](#kling-options) |
| Price | Per token, see [Gemini Omni options](#gemini-omni-options) | $0.43 per second of output | Per second of output, see [Kling options](#kling-options) |
Every other model rejects `inputVideo` with `bad_request`. These models reject it together with frame images (`input_reference` and `last_frame`).
FLUX 3 and Kling get a Puter file as a link that works for 19 minutes, which the provider fetches when the job starts.
## Saving to Puter filesystem
Every other model rejects `inputVideo` with `bad_request`. These models reject it together with frame images (`input_reference` and `last_frame`, and on FLUX 3 also `reference_images` and `frame_images`). Kling takes up to 4 `reference_images` with it.
Pass `puter_output_path` to persist the generated video directly on the Puter filesystem. Relative paths are resolved against `~/AppData/<appID>/` when called from an app, or `~/` otherwise:
#### Together AI options
```js
puter.ai.txt2vid("A drone shot over a forest", {
puter_output_path: "videos/forest.mp4" // saved to ~/AppData/<appID>/videos/forest.mp4
});
```
Absolute paths (`/username/Videos/forest.mp4`) and home-relative paths (`~/Videos/forest.mp4`) are sent as-is. Write permission to the destination is enforced server-side.
The destination is checked before generation starts, so a path you cannot write to fails immediately with `access_denied` and costs nothing. Missing parent folders are created and an existing file at the path is overwritten. Most providers return a temporary hosted URL (see [Return value](#return-value)), so this is the way to keep a clip after the request completes.
## Cost and clip length
Every successful generation is charged to the user's AI credits according to the model, duration and resolution. Before contacting the provider, Puter compares the estimated cost with the remaining balance:
- A model priced per clip renders a fixed-length clip for a flat price, so the request is either accepted at that price or rejected with `insufficient_funds`.
- A model priced by the length of the clip is shortened to fit. If the balance cannot cover the requested length, Puter shortens the clip to the longest duration the model supports that the balance does cover, and rejects with `insufficient_funds` only when even the shortest clip is unaffordable. The response does not flag a shortened clip, so read `video.duration` once metadata has loaded if the exact length matters.
Each model's section under [Model-specific features](#model-specific-features) says how it's priced. A request that fails or times out is not charged, unless the model's section says otherwise. The request and concurrency limits that apply to every AI call are listed in [Rate limits and quotas](/rate-limits-and-quotas/).
## How long it takes
Video generation is slow: expect anywhere from tens of seconds to several minutes, growing with duration and resolution. The returned promise stays pending until the clip is ready, so keep the UI responsive with a progress indicator (see the examples below). Puter waits up to 14 minutes for a job; one that outlives the window fails with `upstream_timeout`.
## Model-specific features
### Together AI models
Available when using any model below (provider `'together-video-generation'`). Pass the model as `'org/model'`, or prefix it with `togetherai:` to make the routing explicit.
@@ -118,6 +161,8 @@ Together AI models are priced in one of two ways, marked in the table:
Most models size their output in pixels: pass `size` as `'WIDTHxHEIGHT'` (or `width` and `height` directly) from the combinations each model advertises in the sizes column. Seedance 2.5 and Wan 2.7 instead work in resolution tiers, so `size` picks the tier of the shorter side (or name the tier directly), and Wan 2.7 takes the aspect ratio from it. "Keyframes" says whether the model can start from an image (`first`) and also end on one (`last`); supply them through `input_reference` and `last_frame`, or through `frame_images` for finer control. "Provider default" means Together AI has not published the values: the model applies its own defaults, and a `seconds` or size it does not accept is rejected with `upstream_bad_request`.
For the other models, the first value listed in a column is the default, unless the row marks another value as the default. A `seconds` or `size` value the model doesn't offer is replaced by that default.
| Model | Pricing | Duration (s) | Sizes | FPS | Keyframes | Notes |
|-------|---------|--------------|-------|-----|-----------|-------|
| `minimax/video-01-director` | Per clip | 5 | `1366x768` | 25 | first | |
@@ -148,9 +193,8 @@ Most models size their output in pixels: pass `size` as `'WIDTHxHEIGHT'` (or `wi
| Option | Type | Description |
|--------|------|-------------|
| `model` | `String` | Video model to use, from the table above |
| `size` | `String` | `'WIDTHxHEIGHT'` from the sizes column, or a tier (`'720p'`, `'480p'`, `'720P'`, `'1080P'`) for Seedance 2.5 and Wan 2.7. `resolution` is an alias |
| `size` | `String` | Either `'WIDTHxHEIGHT'` from the sizes column, or a tier (`'720p'`, `'480p'`, `'720P'`, `'1080P'`) for Seedance 2.5 and Wan 2.7. The `resolution` option is an alias |
| `width` | `Number` | Output video width in pixels. On Wan 2.7, `width` and `height` only select the aspect ratio |
| `height` | `Number` | Output video height in pixels |
| `fps` | `Number` | Frames per second, where the model offers a choice |
| `steps` | `Number` | Number of inference steps |
| `guidance_scale` | `Number` | How closely to follow the prompt |
@@ -161,13 +205,14 @@ Most models size their output in pixels: pass `size` as `'WIDTHxHEIGHT'` (or `wi
| `generate_audio` | `Boolean` | Generate a soundtrack, on models that support audio |
| `input_reference` | `String \| Blob` | First-frame image, in any [image input](#image-inputs) format. Sent as the model's first keyframe |
| `last_frame` | `String \| Blob` | Last-frame image, same formats. Sent as the model's last keyframe |
| `reference_images` | `Array<String \| Blob>` | Reference images to guide the generation |
| `frame_images` | `Array<Object>` | Explicit keyframes, overriding `input_reference` and `last_frame`. Each object has `input_image` (`String` or `Blob`) and `frame` (`Number`, the frame position the image anchors; `0` is the first frame). One image is the first frame; two are the first and last |
| `metadata` | `Object` | Additional metadata for the request |
FLUX 3 (`black-forest-labs/flux-3`) edits a video passed as `inputVideo`. The video can be MP4, QuickTime, WebM or Matroska, of any length, and up to 32 MiB for a Puter file. Together AI fetches a URL itself, and gets a Puter file as a link that works for 19 minutes, which it fetches when the job starts. With `inputVideo`, the output is 1280x704, its length is set by `seconds` from 5 to 20 and defaults to 6, and it costs $0.43 per second of output. FLUX 3 also rejects `reference_images` and `frame_images` together with `inputVideo`.
For more details about each option, see the [Together AI API reference](https://docs.together.ai/reference/create-videos).
#### BytePlus (Seedance) options
### BytePlus (Seedance) models
Available when using a Seedance model served by BytePlus ModelArk (provider `'byteplus-video-generation'`). These models take any whole number of seconds within their range and work in resolution tiers: pass `size` as a tier or as `'WIDTHxHEIGHT'`, which selects the tier of the shorter side and the aspect ratio.
@@ -180,17 +225,17 @@ Available when using a Seedance model served by BytePlus ModelArk (provider `'by
| `seedance-1-0-pro-250528` | `seedance-1-0-pro` | 2 to 12, default 5 | `1080p`, `480p`, `720p` | No | Yes | No | Yes |
| `seedance-1-0-pro-fast-251015` | `seedance-1-0-pro-fast` | 2 to 12, default 5 | `1080p`, `480p`, `720p` | No | No | No | Yes |
The first resolution listed is the default. Seedance is billed per video token, and the token count grows with duration and pixel count, so a 4K clip costs several times more per second than 720p. Generated clips carry no watermark.
The first resolution listed is the default. Seedance is billed per video token, and the token count grows with duration and pixel count, so a 4K clip costs several times more per second than 720p. Since the price grows with length, a clip is shortened to fit the balance, as described in [Cost and clip length](#cost-and-clip-length). Generated clips carry no watermark.
| Option | Type | Description |
|--------|------|-------------|
| `model` | `String` | Video model to use, from the table above (full id or alias) |
| `seconds` | `Number` | Clip length, any whole number of seconds in the model's range. Out-of-range values fall back to `5` |
| `size` | `String` | Resolution tier: `'480p'`, `'720p'`, `'1080p'` or `'4k'` (case-insensitive) from the table above, or `'WIDTHxHEIGHT'`. `resolution` is an alias |
| `width`, `height` | `Number` | Optional. Only their ratio is used, to pick the output aspect ratio: `16:9`, `4:3`, `1:1`, `3:4`, `9:16` or `21:9`. Any other ratio is ignored and the model chooses |
| `size` | `String` | Resolution tier: `'480p'`, `'720p'`, `'1080p'` or `'4k'` (case-insensitive) from the table above, or `'WIDTHxHEIGHT'`. The `resolution` option is an alias |
| `width` | `Number` | Optional. Only the ratio of `width` to `height` is used, to pick the output aspect ratio: `16:9`, `4:3`, `1:1`, `3:4`, `9:16` or `21:9`. Any other ratio is ignored and the model chooses |
| `height` | `Number` | Works the same way as `width` |
| `generate_audio` | `Boolean` | Generate a soundtrack along with the video. Defaults to `true` on models that support audio. Set `false` for a silent clip, which on Seedance 1.5 Pro is also cheaper |
| `seed` | `Number` | Random seed for reproducible results. Seedance 1.x only |
| `input_reference` | `String \| Blob` | Image used as the first frame (image-to-video), in any [image input](#image-inputs) format |
| `last_frame` | `String \| Blob` | Image used as the last frame, same formats. Requires `input_reference` |
| `reference_images` | `Array<String \| Blob>` | Images the model uses as subject or style references, same formats. Up to 9 on the Seedance 2.0 models, up to 30 on Seedance 2.5. Cannot be combined with `input_reference` or `last_frame` |
@@ -198,9 +243,9 @@ An invalid combination (a last frame without a first frame, reference images tog
For more details, see the [BytePlus ModelArk video generation reference](https://docs.byteplus.com/en/docs/ModelArk/1520757).
#### Gemini Omni options
### Gemini Omni models
Available when using `gemini-omni-1.1-flash` (provider `'gemini-video-generation'`), which also answers to `google/gemini-omni-1.1-flash`. It's offered when the Puter deployment has Gemini configured. It generates a clip from a prompt, from a prompt and one starting image, or from a prompt and a video to edit. The model picks the clip length and renders at 24 frames per second.
Available when using `gemini-omni-1.1-flash` (provider `'gemini-video-generation'`), which also answers to `google/gemini-omni-1.1-flash`. It generates a clip from a prompt, from a prompt and one starting image, or from a prompt and a video to edit. The model picks the clip length and renders at 24 frames per second. When editing an `inputVideo`, the prompt can also ask it to continue the clip past its end.
| `size` | 16:9 (default) | 9:16 |
|--------|----------------|------|
@@ -214,16 +259,19 @@ Google produces 1080p and 4K by upscaling. Pass the resolution on its own for 16
| Option | Type | Description |
|--------|------|-------------|
| `prompt` | `String` | Up to 4,000 characters |
| `size` | `String` | A resolution or exact size from the table above, case-insensitive. Defaults to `'720p'` at 16:9. `resolution` is an alias, and must match `size` when you pass both |
| `width`, `height` | `Number` | Together, an exact size from the table above. They must match `size` when you pass it |
| `size` | `String` | A resolution or exact size from the table above, case-insensitive. Any other value is rejected with `bad_request`. Defaults to `'720p'` at 16:9. The `resolution` option is an alias, and must match `size` when you pass both |
| `width` | `Number` | Together with `height`, an exact size from the table above. Both must match `size` when you pass it |
| `height` | `Number` | Works the same way as `width` |
| `input_reference` | `String \| Blob` | The image the clip starts from, in any [image input](#image-inputs) format. PNG, JPEG or WebP, up to 5 MiB |
| `inputVideo` | `String \| Blob` | The video to edit or extend, in any format [Video input](#video-input) lists. MP4, QuickTime or WebM, up to 32 MiB and 10 seconds |
| `inputVideo` | `String \| Blob` | The video to edit or extend, in any format [Video input](#video-input) lists. MP4, QuickTime or WebM, up to 32 MiB and 10 seconds. A longer video fails with `upstream_bad_request` and isn't charged |
| `output_format` | `String` | Only `'video/mp4'` |
Gemini Omni rejects `seconds`, `duration`, `fps`, `steps`, `guidance_scale`, `seed`, `negative_prompt`, `output_quality`, `generate_audio`, `frame_images`, `last_frame`, `reference_images` and `metadata` with `bad_request`, test mode included, since it has no matching control.
Gemini Omni is priced per token. Input costs $1.50 per million tokens. Video output costs $17.50 per million tokens. Other output and the model's thinking cost $9 per million tokens. The prompt and image or video together are limited to 131,072 input tokens. A source video costs about 1,500 input tokens per second, so a 10-second video adds about $0.02. Other limits are listed in [Rate limits and quotas](/rate-limits-and-quotas/#video-generation).
Gemini Omni has no length control, so it's never shortened. Before generating, Puter counts the input tokens and holds an estimate of about $3.63 plus the input. The estimate covers 173,760 video tokens and 65,536 text and thinking tokens. Puter doesn't count the tokens of an `inputVideo`, so a request with one holds another 16,000 input tokens, about $0.02, which covers a 10-second video. Puter reads the video only after the estimate is held. A balance that can't cover it fails with `insufficient_funds` before generation starts. The charge is for the tokens Google reports for the clip. It can be higher than the estimate, because Google can go past the requested output limit. When Google's reply reports token usage but fails or has no usable clip, those tokens are charged and the request still fails.
```html;ai-txt2vid-gemini
<script src="https://js.puter.com/v2/"></script>
<script>
@@ -234,7 +282,7 @@ Gemini Omni is priced per token. Input costs $1.50 per million tokens. Video out
</script>
```
#### Kling options
### Kling models
Available when using a Kling model (provider `'kling-video-generation'`). Every model also answers to `kling/<model>` and to Kling's own model name, listed as the alias. Kling models take any whole number of seconds in their range and work in resolution tiers: pass `size` as a tier, or as `'WIDTHxHEIGHT'`, which selects the tier of the shorter side and the aspect ratio. They render at 24 frames per second.
@@ -258,24 +306,28 @@ The first resolution listed is the default. Prices are per second of output:
| `kling-2.6` | $0.042 | $0.07, $0.14 with audio | |
| `kling-2.5-turbo` | $0.042 | $0.07 | |
A clip is shortened to fit the balance, as described in [Cost and clip length](#cost-and-clip-length). An edit of an `inputVideo` is as long as its source, so Puter holds the price of the longest source the model accepts (16 seconds on Kling 3.0 Omni, 10 on Kling O1) and charges for the length of the clip Kling returns.
| Option | Type | Description |
|--------|------|-------------|
| `model` | `String` | Video model to use, from the table above (full id or alias) |
| `prompt` | `String` | Up to 3,072 characters on the Kling 3.0 models, 2,500 on the others and on Kling 3.0 Turbo with `input_reference` |
| `seconds` | `Number` | Clip length, from the table above. Any other value falls back to `5`. Ignored with `inputVideo` |
| `size` | `String` | Resolution tier: `'720p'`, `'1080p'` or `'4k'` (case-insensitive), or `'WIDTHxHEIGHT'`. `resolution` is an alias |
| `width`, `height` | `Number` | Optional. Only their ratio is used, to pick the aspect ratio: `16:9`, `9:16` or `1:1`. Any other ratio gives `16:9`. A clip from `input_reference` or `inputVideo` takes the image's or video's shape |
| `size` | `String` | Resolution tier: `'720p'`, `'1080p'` or `'4k'` (case-insensitive), or `'WIDTHxHEIGHT'`. The `resolution` option is an alias |
| `width` | `Number` | Optional. Only the ratio of `width` to `height` is used, to pick the aspect ratio: `16:9`, `9:16` or `1:1`. Any other ratio gives `16:9`. A clip from `input_reference` or `inputVideo` takes the image's or video's shape |
| `height` | `Number` | Works the same way as `width` |
| `generate_audio` | `Boolean` | Generate a soundtrack on models with optional audio. Defaults to `false`. With `inputVideo`, the clip keeps the source video's sound unless this is `false` |
| `input_reference` | `String \| Blob` | Image used as the first frame, in any [image input](#image-inputs) format. JPEG or PNG, at least 300 pixels per side |
| `last_frame` | `String \| Blob` | Image used as the last frame, same formats. Requires `input_reference` |
| `reference_images` | `Array<String \| Blob>` | Images the model uses as subject, style or scene references, same formats. Kling O1 doesn't take them with `last_frame` |
| `inputVideo` | `String \| Blob` | Video to edit, described by the prompt. See [Video input](#video-input) |
An option the model lacks, or a combination it doesn't support, is rejected with `bad_request` before any credits are spent. Clips carry no watermark. Kling returns a hosted URL that stays available for 30 days.
Kling 3.0 Omni and Kling O1 edit a video passed as `inputVideo`. The video must be MP4 or QuickTime, up to 200 MiB for a Puter file, and 3 to 15.5 seconds long on Kling 3.0 Omni or 3 to 10 seconds on Kling O1. Kling doesn't take a `data:` URI. It fetches a URL itself, and gets a Puter file as a link that works for 19 minutes, which it fetches when the job starts. A video Kling can't decode fails with `upstream_bad_request` and isn't charged. Up to 4 `reference_images` can go with the video.
For more details, see the [Kling video API reference](https://kling.ai/document-api/api/video/3-0-omni/text-to-video).
#### MiniMax options
### MiniMax models
Available when using a MiniMax model (provider `'minimax-video-generation'`). The ids are MiniMax's own model names in lower case, and every model also answers to `minimax/<model>`. The H3 models take any whole number of seconds in their range and are priced per second. The Hailuo models render a 6 or 10 second clip for a flat price. All of them work in resolution tiers: pass `size` as a tier, or as `'WIDTHxHEIGHT'`, which selects the tier of the shorter side and the aspect ratio. Pass `'2k'` by name. They render at 24 frames per second.
@@ -301,13 +353,16 @@ The first resolution listed is the default. "Images in total" counts `input_refe
| `minimax-hailuo-2.3-fast` | $0.19 for 6 seconds at `768p`, $0.32 for 10 seconds at `768p`, $0.33 for 6 seconds at `1080p` |
| `minimax-hailuo-02` | As Hailuo 2.3, plus $0.10 for 6 seconds and $0.15 for 10 seconds at `512p` |
An H3 clip is shortened to fit the balance, as described in [Cost and clip length](#cost-and-clip-length), and charged for the seconds of output MiniMax reports. The Hailuo models are priced per clip, so those requests are either accepted at the clip's price or rejected with `insufficient_funds`.
| Option | Type | Description |
|--------|------|-------------|
| `model` | `String` | Video model to use, from the table above |
| `prompt` | `String` | Up to 7,000 characters on the H3 models, 2,000 on the Hailuo models |
| `seconds` | `Number` | Clip length, from the table above. Any other value falls back to the default. On the Hailuo models, 10 seconds at `1080p` falls back to 6 |
| `size` | `String` | Resolution tier from the table above (case-insensitive), or `'WIDTHxHEIGHT'`. `resolution` is an alias |
| `width`, `height` | `Number` | Optional. Only their ratio is used, to pick the aspect ratio of an H3 clip made from text or `reference_images`: `21:9`, `16:9`, `4:3`, `1:1`, `3:4` or `9:16`. Any other ratio gives `16:9`. A clip from `input_reference` or `last_frame` takes the image's shape. The Hailuo models ignore them |
| `size` | `String` | Resolution tier from the table above (case-insensitive), or `'WIDTHxHEIGHT'`. The `resolution` option is an alias |
| `width` | `Number` | Optional. Only the ratio of `width` to `height` is used, to pick the aspect ratio of an H3 clip made from text or `reference_images`: `21:9`, `16:9`, `4:3`, `1:1`, `3:4` or `9:16`. Any other ratio gives `16:9`. A clip from `input_reference` or `last_frame` takes the image's shape. The Hailuo models ignore them |
| `height` | `Number` | Works the same way as `width` |
| `input_reference` | `String \| Blob` | Image used as the first frame, in any [image input](#image-inputs) format. Required on Hailuo 2.3 Fast. JPEG, PNG or WebP, at least 256 pixels per side on the H3 models and more than 300 pixels on the short side on the Hailuo models |
| `last_frame` | `String \| Blob` | Image used as the last frame, same formats. Requires `input_reference` and `768p` or `1080p` on Hailuo 02. The H3 models also take it alone |
| `reference_images` | `Array<String \| Blob>` | Images the model uses as subject, style or scene references, same formats, on the H3 models. Cannot be combined with `input_reference` or `last_frame` |
@@ -318,81 +373,9 @@ For more details, see the [MiniMax video API reference](https://platform.minimax
Any properties not set fall back to provider defaults.
#### Saving to Puter filesystem
Pass `puter_output_path` to persist the generated video directly on the Puter filesystem. Relative paths are resolved against `~/AppData/<appID>/` when called from an app, or `~/` otherwise:
```js
puter.ai.txt2vid("A drone shot over a forest", {
puter_output_path: "videos/forest.mp4" // saved to ~/AppData/<appID>/videos/forest.mp4
});
```
Absolute paths (`/username/Videos/forest.mp4`) and home-relative paths (`~/Videos/forest.mp4`) are sent as-is. Write permission to the destination is enforced server-side.
The destination is checked before generation starts, so a path you cannot write to fails immediately with `access_denied` and costs nothing. Missing parent folders are created and an existing file at the path is overwritten. Most providers return a temporary hosted URL (see [Return value](#return-value)), so this is the way to keep a clip after the request completes.
#### Cost and clip length
Every successful generation is charged to the user's AI credits according to the model, duration and resolution. Before contacting the provider, Puter compares the estimated cost with the remaining balance:
- **Seedance** is priced per second. If the balance cannot cover the requested length, Puter shortens the clip to the longest duration the model supports that the balance does cover, and rejects with `insufficient_funds` only when even the shortest clip is unaffordable. The response does not flag a shortened clip, so read `video.duration` once metadata has loaded if the exact length matters.
- **Together AI** prices its older models per clip, so those requests are either accepted at the model's duration or rejected with `insufficient_funds`. Its per-second models (marked in the Together AI table), including the default Wan 2.7, are shortened like Seedance, and charged at the model's highest published rate per second.
- **Kling** is priced per second, and clips are shortened like Seedance. An edit of an `inputVideo` is as long as its source, so Puter holds the price of the longest source the model accepts (16 seconds on Kling 3.0 Omni, 10 on Kling O1) and charges for the length of the clip Kling returns.
- **MiniMax** H3 models are priced per second, and clips are shortened like Seedance. The charge is for the seconds of output MiniMax reports. The Hailuo models are priced per clip, so those requests are either accepted at the clip's price or rejected with `insufficient_funds`.
- **Gemini Omni** is priced per token and has no length control, so it's never shortened. Before generating, Puter counts the input tokens and holds an estimate of about $3.63 plus the input. The estimate covers 173,760 video tokens and 65,536 text and thinking tokens. Puter doesn't count the tokens of an `inputVideo`, so a request with one holds another 16,000 input tokens, about $0.02, which covers a 10-second video. Puter reads the video only after the estimate is held. A balance that can't cover it fails with `insufficient_funds` before generation starts. The charge is for the tokens Google reports for the clip. It can be higher than the estimate, because Google can go past the requested output limit. When Google's reply reports token usage but fails or has no usable clip, those tokens are charged and the request still fails.
Apart from that Gemini Omni case, a request that fails or times out is not charged. The request and concurrency limits that apply to every AI call are listed in [Rate limits and quotas](/rate-limits-and-quotas/).
#### How long it takes
Video generation is slow: expect anywhere from tens of seconds to several minutes, growing with duration and resolution. The returned promise stays pending until the clip is ready, so keep the UI responsive with a progress indicator (see the examples below). Puter waits up to 14 minutes for a job; one that outlives the window fails with `upstream_timeout`.
## Return value
A `Promise` that resolves to an `HTMLVideoElement` (in browsers) that you can append to the DOM straight away:
- `src` is the clip's URL: an `https:` URL on the provider's storage (Together AI, Seedance, Kling or MiniMax), or a `data:video/mp4;base64,…` URI holding the whole clip (Gemini Omni). Provider URLs are temporary; keep a copy with `puter_output_path` if you need the clip later.
- `controls` is enabled and `preload` is `"metadata"`.
- The `data-source` attribute carries the same URL, and `data-mime-type` the MIME type when it is known (for example `video/mp4`).
- `String(video)` returns the URL, so the element can be dropped into a template or passed to `fetch()`.
In Node.js and Workers, where there is no DOM, the promise resolves to a plain object with the same `src`, `controls` and `preload` fields and the same `toString()`; the `data-*` attributes are not present. Use `fetch(video.src)` to read the bytes, or `puter_output_path` to have Puter store the file for you.
In test mode `src` is always the `https:` URL of Puter's sample clip.
> **Note:** Each successful generation consumes the user's AI credits in accordance with the model, duration and resolution you request. See [Cost and clip length](#cost-and-clip-length) for how Puter fits a clip to the remaining balance.
## Errors
A rejection carries the error body exactly as the backend sent it, or `{ message, code }` for the checks the SDK runs before making the request. Every error has `message` and `code`; the other fields appear when they apply.
| Field | Meaning |
| --- | --- |
| `message` | Human-readable reason. `error` carries the same text for older clients. |
| `code` | Stable error code; see the table below. |
| `errorCode` | A more specific code alongside a general `code`. Today the only value is `moderation_flagged`. |
| `provider` | Which upstream handled the request: `together`, `byteplus`, `gemini`, `kling` or `minimax`. Present on errors raised while a job was running. |
| `upstreamCode` | The provider's own error code, when it gave one. |
| `upstreamStatus` | The HTTP status the provider returned, when it rejected the request before a job started. |
| Code | Meaning |
| --- | --- |
| `prompt_required` | Raised by the SDK before any request is made: the call had no prompt. |
| `bad_request` (without `errorCode`) | Puter rejected the request before contacting a provider: an unknown `model` (`Model not found: …`), an invalid combination of image inputs, an option Gemini Omni doesn't support, `inputVideo` on a model that can't take it, an image or video URL that could not be fetched, or a Puter file that isn't an accepted image or video format. `message` says which. Arrives as HTTP 400. |
| `input_too_large` | An image or video input is over its size limit. The limits are 5 MiB for an image on Gemini Omni, 32 MiB for a video, 1 MiB for a video sent as a `data:` URI, and 32 MiB for the Puter file images of one request together. Arrives as HTTP 413, before any credits are spent. |
| `access_denied` | `puter_output_path` points somewhere the caller may not write, or an image or video input names a Puter file the caller may not read. Arrives as HTTP 403, before any credits are spent. `cannot_write_to_root` (HTTP 400) is the same check for a path directly under `/`. |
| `upstream_timeout` | The provider did not finish the clip within the 14 minutes Puter waits for it, or stopped answering. Arrives as HTTP 504. The request itself was fine; retry it, ideally with a shorter clip or a faster model. |
| `errorCode: moderation_flagged` | The provider's content filter refused the prompt or removed the generated video. Arrives as HTTP 400, with `code: bad_request`. Change the prompt rather than retrying it as-is. |
| `upstream_bad_request` | The provider rejected the request itself, for example a duration the model does not support. Arrives as HTTP 400; `message` and `upstreamCode` carry the provider's reason. |
| `upstream_failed` | The provider accepted the request but generation failed on their side. Arrives as HTTP 502 and is safe to retry. |
| `insufficient_funds` | Your balance cannot cover even the shortest clip the model offers (or, for a per-clip Together AI model, the clip). Arrives as HTTP 402; `message` states the shortfall. |
Other `upstream_*` codes mean the provider rejected the request or was unavailable before a job started; `message` carries the provider's reason.
## Examples
<strong class="example-title">Generate a sample clip (test mode)</strong>
### Generate a sample clip (test mode)
```html;ai-txt2vid
<html>
@@ -410,7 +393,7 @@ Other `upstream_*` codes mean the provider rejected the request or was unavailab
</html>
```
<strong class="example-title">Generate an 8-second cinematic clip in 1080p</strong>
### Generate an 8-second cinematic clip in 1080p
```html;ai-txt2vid-options
<html>
@@ -431,7 +414,7 @@ Other `upstream_*` codes mean the provider rejected the request or was unavailab
</html>
```
<strong class="example-title">Use Google Veo through Together AI with a negative prompt</strong>
### Use Google Veo through Together AI with a negative prompt
```html;ai-txt2vid-veo
<html>
@@ -450,7 +433,7 @@ Other `upstream_*` codes mean the provider rejected the request or was unavailab
</html>
```
<strong class="example-title">Animate a photo (image-to-video)</strong>
### Animate a photo (image-to-video)
```html;ai-txt2vid-image-to-video
<html>
@@ -494,7 +477,7 @@ Other `upstream_*` codes mean the provider rejected the request or was unavailab
</html>
```
<strong class="example-title">Save the clip to the Puter filesystem</strong>
### Save the clip to the Puter filesystem
```html;ai-txt2vid-save
<html>
@@ -516,7 +499,7 @@ Other `upstream_*` codes mean the provider rejected the request or was unavailab
</html>
```
<strong class="example-title">Show progress and handle errors</strong>
### Show progress and handle errors
```html;ai-txt2vid-errors
<html>
@@ -559,7 +542,7 @@ Other `upstream_*` codes mean the provider rejected the request or was unavailab
</html>
```
<strong class="example-title">Generate a clip from Node.js</strong>
### Generate a clip from Node.js
```js
import { init } from "@heyputer/puter.js/src/init.cjs";
+3
View File
@@ -264,6 +264,7 @@ These Events features are supported out of the box when using Puter.js:
- **[`puter.events.onSchedule()`](/Events/onSchedule/)** - Run a handler at an interval, on a cron expression, or once at a set time
- **[`puter.events.listSchedules()`](/Events/listSchedules/)** - List the schedules you hold
- **[`puter.events.getSchedule()`](/Events/getSchedule/)** - Read one schedule
- **[`puter.events.updateSchedule()`](/Events/updateSchedule/)** - Move a schedule or change its context, or edit a stored notification
- **[`puter.events.removeSchedule()`](/Events/removeSchedule/)** - End a schedule
- **[`puter.events.fetch()`](/Events/fetch/)** - Read the notifications you missed while nothing was listening
- **[`puter.events.handlers`](/Events/handlers/)** - Publish, list and remove the named handlers persistent subscriptions and schedules run, for the app or for one user
@@ -289,6 +290,8 @@ You can see various Puter.js Events features in action from the following exampl
- Schedules
- [List Schedules](/playground/events-list-schedules/)
- [Read a Schedule](/playground/events-get-schedule/)
- [Move a Reminder](/playground/events-update-reminder/)
- [Move a Schedule to Another Time](/playground/events-update-cron/)
- [Remove a Schedule](/playground/events-remove-schedule/)
- [Run Code Once at a Set Time](/playground/events-schedule-inline/)
- [Run Code on Weekday Mornings](/playground/events-schedule-cron-tz/)
+1 -1
View File
@@ -45,7 +45,7 @@ The promise rejects with `{ message, code }`:
| `code` | Meaning |
| --- | --- |
| `invalid_request` | `includeTotal` isn't a boolean. |
| `bad_request` | `limit` isn't a positive number. |
| `bad_request` | `limit` isn't a positive number, or `cursor` can't be read. |
| `auth_canceled` | Nobody was signed in, and the user closed the sign-in without finishing it. |
| `too_many_requests` | Over the listing rate limit, which is shared with [`getSchedule()`](/Events/getSchedule/) and the subscription listings. |
| `events_disabled` | Schedules aren't available on this server, as on self-hosted servers. |
+1 -1
View File
@@ -75,7 +75,7 @@ The promise rejects with `{ message, code }`:
All of a client's session subscriptions share one connection. The first `onLocal()` opens it, and it closes when the last subscription ends.
If the connection drops (a network blip, a sign-in, an API origin change, or the server closing it), the SDK reconnects and subscribes again. The handler and the subscription object stay the same, and only `subId` changes. Changes made while the connection was down aren't sent afterwards. Once the subscription is back, the handler gets one [gap marker](/Events/subjects/#gaps) with `reason: 'reconnect'`, so a handler that re-reads on gaps catches up by itself. For `notif:` subjects, [`fetch()`](/Events/fetch/) reads exactly what was missed.
If the connection drops (a network blip, a sign-in, an API origin change, or the server closing it), the SDK reconnects and subscribes again. The handler and the subscription object stay the same, and only `subId` changes. Changes made while the connection was down aren't sent afterwards. Once the subscription is back, the handler gets one [gap marker](/Events/subjects/#gaps) with `reason: 'reconnect'`, so a handler that re-reads on gaps catches up by itself. For `notif:` subjects, [`fetch()`](/Events/fetch/) reads missed postings. Live dismissals aren't replayed by that call. The [notification listeners](/Notifications/onNotification/) separately check displayed browser notifications for dismissals.
The SDK calls `onError` only if the subscription can't be restored. That's when subscribing again fails, when the reconnect is refused (`reauth_required` after a sign-out), or when the server keeps closing the connection (`events_connection_failed`).
+11 -4
View File
@@ -66,7 +66,7 @@ A subscription suspended with `permission_revoked` doesn't resume when access is
- **`onPersistent()`** runs your handler in the events worker for every event, even when your app is closed, and also sends the event to every connected client. Use it to react to every change, such as keeping an index or a notification up to date.
- **`onPersistent({ queue: true })`** hands each event to one consumer at a time and retries it when the run never finished or failed for a temporary reason. Use it for work that has to finish, such as processing every uploaded file or calling a webhook.
A queued event comes back only on a retry or a lost answer, with the same `event.id`, so check that id before doing work twice.
Queue subscriptions skip the 250 ms coalescing window, so quick changes stay separate. A queued event comes back only on a retry or a lost answer, with the same `event.id`, so check that id before doing work twice.
## Where the handler runs
@@ -86,7 +86,7 @@ Wherever it runs, the handler is the same code, called with:
| `event` | The [event](/Events/subjects/#the-event), or a [gap marker](/Events/subjects/#gaps). |
| `ctx` | The frozen `context` this subscription was created with. |
| `user` | A `puter` bound to the account holding the subscription, with the same access your app has for that user in a tab. In a client, it's the page's own `puter`. |
| `fetch` | [`puter.net.fetch`](/Networking/fetch/) where it exists, otherwise the environment's `fetch`. |
| `fetch` | In a client, [`puter.net.fetch()`](/Networking/fetch/) where it exists, otherwise the environment's `fetch`. In the events worker, the environment's native `fetch`. |
| `ack` | On a `queue` subscription only. Optional, see [What happens when a handler fails](#what-happens-when-a-handler-fails). |
Those five bindings are the handler's whole environment. The events worker has no global `puter`, so a handler that names `puter` or `me` is refused when you publish it. Use `user` instead. In the events worker, `user` is valid for 15 minutes, so don't keep it past the run.
@@ -121,7 +121,7 @@ Five failures in a row suspend the subscription with `failures`, and publishing
Without `queue`, a failed run is never retried.
A handler can call `ack()` to mark the event done before it finishes. A throw after that doesn't undo it.
In a connected client, a handler can call `ack()` to mark the event done before it finishes. In the events worker, `ack()` prevents a later throw from rejecting the delivery, but the answer waits until the handler finishes. It doesn't prevent a timeout or a lost answer from causing a retry.
## Context is captured once, up to 4 KB
@@ -161,7 +161,7 @@ Clients connect to the nearest region. Events reach a client wherever it's conne
- Without `queue`, an event runs the handler in the events worker and in each connected client that passed a function `handler`. A run that fails or happens while nothing is connected isn't retried.
- `queue` deliveries are at-least-once. An event whose run never finished, or failed with a temporary error, arrives again with the same `event.id`.
- Undelivered events are held in the region where the change happened. If that region goes down, only what it was holding is lost.
- Order is kept within a region and is best effort across regions. Coalescing (250 ms) also runs per region, so two quick writes can arrive as one event nearby and as two farther away.
- Order is kept within a region and is best effort across regions. Without `queue`, coalescing (250 ms) also runs per region, so two quick writes can arrive as one event nearby and as two farther away.
## Return value
@@ -182,6 +182,11 @@ The promise rejects with `{ message, code }`:
| `code` | Meaning |
| --- | --- |
| `events_handler_conflict` | The inline handler couldn't be confirmed after a concurrent publish. Try again. |
| `events_handler_hash_mismatch` | The published handler isn't the source passed as `handler`. |
| `events_handler_not_found` | Nothing is published under `handlerName` in that scope, or source was supplied to check a named handler outside an app. |
| `events_handler_hash_unavailable` | This environment has no `crypto.subtle`, so `handler` can't be hashed. Pass `handlerName` alone. |
| `events_handler_invalid` | `handler` isn't a function, a nonempty source string, or `{ file }`. |
| `invalid_subject` | The subject is empty, not a string, or can't be parsed, or it's a `notif:` subject and this server has notification events turned off. |
| `auth_canceled` | Nobody was signed in, and the user closed the sign-in without finishing it. |
| `events_user_handlers_disabled` | Publishing into a user scope isn't turned on for this server. |
@@ -213,6 +218,8 @@ The promise rejects with `{ message, code }`:
| `events_disabled` | Events aren't enabled on this server. |
| `events_failed` | The server sent a response the SDK couldn't read. |
With a `{ file }` handler, errors from [`puter.fs.read()`](/FS/read/) also pass through.
## Examples
The first example is the inline form, which any app can use for the signed-in user. The others bind to a handler the app's owner published, so they run in your own app or from Node.js signed in as its developer.
+17 -8
View File
@@ -14,7 +14,7 @@ The handler is either inline code you pass as `handler`, or a handler published
To run a handler when the user's data changes instead, use [`puter.events.onPersistent()`](/Events/onPersistent/).
[Change Data at a Set Time](/recipes/change-data-at-a-set-time/) walks through a one-time schedule with `at`, including how to cancel and move it.
To change the timing or `context` later, use [`updateSchedule()`](/Events/updateSchedule/). [Change Data at a Set Time](/recipes/change-data-at-a-set-time/) walks through a one-time schedule with `at`, including how to cancel and move it.
Only an app can create a schedule, meaning an app running on Puter or a website using Puter.js. The schedule belongs to the user the app runs for, and to that app. On a website with nobody signed in, it asks the user to sign in first, as other Puter.js calls do. An app running on Puter is always signed in.
@@ -40,7 +40,7 @@ Give exactly one of `every`, `cron` or `at`.
- `handlerName` (String): The published handler each run calls. Required unless you pass `handler`.
- `handlerScope` (String): Where `handlerName` is looked up, `'app'` or `'user'`. Defaults to `'app'`, the handlers the app's owner published. `'user'` binds to a handler the signed-in user's scope holds, published with `handlers.publish(name, handler, { scope: 'user' })`.
- `handlerHash` (String): The SHA-256 hex digest of the handler source, in place of `handler`, to check it against `handlerName`.
- `context` (Object): Values the handler needs, passed to every run as a frozen `ctx`. Up to 4 KB serialized. It's read once, at this call, and can't be changed later. To change it, remove the schedule and create a new one.
- `context` (Object): Values the handler needs, passed to every run as a frozen `ctx`. Up to 4 KB serialized. It's read once, at this call. To replace it, use [`updateSchedule()`](/Events/updateSchedule/).
### Times
@@ -107,7 +107,7 @@ A schedule can't run more often than your plan's minimum interval, which is 1 mi
The plan also limits how many schedules the user can hold for your app and in total, and how many runs per hour all of them can add up to. Over those limits, the call fails with `events_schedule_limit` or `events_schedule_budget`. With `maxRuns` on a `cron` schedule, the last run has to fall inside those same 10,000 runs or 400 days, or the call fails with `events_schedule_invalid`. See [Rate Limits and Quotas](/rate-limits-and-quotas/#schedules) for the numbers.
The plan's limits are checked when a schedule is created. A schedule keeps running if the user later moves to a plan with lower limits. Schedules created after that are held to the new plan.
The plan's limits are checked when a schedule is created, and when [`updateSchedule()`](/Events/updateSchedule/) changes its timing. A schedule keeps running if the user later moves to a plan with lower limits. Schedules created after that are held to the new plan.
## When a schedule runs
@@ -119,7 +119,7 @@ A run can start later than 60 seconds after its slot when it's being retried, wh
Puter starts one attempt at a time for each schedule, and the next slot waits until the current run finishes or is given up. A handler that's still running when its 30 seconds are up can overlap its own retry, which has the same `event.id`.
When a schedule falls behind, for example while a run was being retried, it doesn't make up every slot it missed. It runs the latest slot that's due, once, and `event.missed` counts the slots it skipped. A schedule that resumes after a suspension waits for its next slot, and that run's `missed` counts the slots it skipped while it was suspended.
When a schedule falls behind, for example while a run was being retried, it doesn't make up every slot it missed. It runs the latest slot that's due, once, and `event.missed` counts the slots it skipped. A recurring schedule that resumes after a suspension waits for its next slot, and that run's `missed` counts the slots it skipped while it was suspended. An `at` schedule whose time passed while it was suspended runs once when it resumes, as [Suspended and ended schedules](#suspended-and-ended-schedules) describes.
## Delivery guarantees
@@ -191,12 +191,14 @@ A schedule can stop running without being removed. It's then suspended, and [`li
A suspended schedule isn't billed. One that stays suspended for 30 days ends.
An `at` schedule whose time passed while it was suspended, for any reason, runs once when it resumes. Its `event.scheduledFor` is the original time and `event.firedAt` is when the run started, so a handler that shouldn't act that late can compare them and return.
An ended schedule never runs again. It has `state: 'ended'` and an `endReason`, and stays in [`listSchedules()`](/Events/listSchedules/) for 7 days, after which it is deleted:
| `endReason` | Cause |
| --- | --- |
| `removed` | It was removed with [`removeSchedule()`](/Events/removeSchedule/) or `off()`. |
| `ended` | Its last run is done (an `at` schedule, `expiresAt` or `maxRuns`), or it stayed suspended for 30 days. |
| `ended` | Its last run finished, a recurring schedule's last slot passed while suspended, or it stayed suspended for 30 days. |
| `permission_revoked` | The user revoked `events:background` for your app. |
| `uninstalled` | The user uninstalled your app. |
| `app_deleted` | The app was deleted. |
@@ -210,7 +212,7 @@ When schedules end with `permission_revoked`, the user's app gets an `app.events
A `Promise` that resolves to the schedule. All times are in milliseconds since the epoch.
- `scheduleId` (String): Its id, which [`getSchedule()`](/Events/getSchedule/) and [`removeSchedule()`](/Events/removeSchedule/) take.
- `scheduleId` (String): Its id, which [`getSchedule()`](/Events/getSchedule/), [`updateSchedule()`](/Events/updateSchedule/) and [`removeSchedule()`](/Events/removeSchedule/) take.
- `kind` (String): `'recurring'` for `every` and `cron`, `'once'` for `at`, and `'deferred'` for a notification sent later with [`notify()`](/Notifications/notify/#send-it-later).
- `appUid` (String): The app that created it.
- `handlerName` (String | null): The handler each run calls, or `null` once the schedule has ended. For a schedule created with an inline `handler`, it's the name Puter gave the handler, which starts with `@`.
@@ -221,7 +223,7 @@ A `Promise` that resolves to the schedule. All times are in milliseconds since t
- `tz` (String): The time zone of a `cron` schedule, and `'UTC'` for the others.
- `startAt` (Number): The first time it may run. For `at`, its only run.
- `endsAt` (Number | null): The last time it may run, from `expiresAt` or `maxRuns`. For `at`, its only run.
- `state` (String): `'active'`, `'suspended'`, `'sending'`, or `'ended'`. A delayed notification is `'sending'` once it has started to go out.
- `state` (String): `'active'`, `'suspended'`, `'sending'`, or `'ended'`. A delayed notification is `'sending'` during a send attempt. A failed attempt can return it to `'active'` before retrying.
- `suspendedReason` (String | null): Why it's [suspended](#suspended-and-ended-schedules).
- `endReason` (String | null), `endedAt` (Number | null): Why and when it [ended](#suspended-and-ended-schedules).
- `nextRunAt` (Number | null): The next slot of the schedule, worked out from its definition and the clock. A late or retried run for an earlier slot can still happen first. After the last slot is due, it stays at that slot until the run for it is done. It's `null` while the schedule is suspended or sending, and once it has ended.
@@ -232,6 +234,8 @@ The promise rejects with `{ message, code }`:
| `code` | Meaning |
| --- | --- |
| `events_value_too_large` | A field is longer than can be stored. |
| `events_handler_conflict` | The inline handler couldn't be confirmed after a concurrent publish. Try again. |
| `invalid_request` | `options` isn't an object. |
| `events_handler_required` | There's neither a `handler` nor a `handlerName`. |
| `events_user_handlers_disabled` | Publishing into a user scope isn't turned on for this server. |
@@ -256,7 +260,7 @@ The promise rejects with `{ message, code }`:
| `events_schedule_never_fires` | The schedule would never run. Its first run or its `startAt` is more than 366 days away, or it ends before its first run. |
| `events_schedule_too_frequent` | It would run more often than your plan's [minimum interval](#how-often-a-schedule-can-run). |
| `events_schedule_too_deep` | The call was made from a handler at the end of a [handler chain](/Events/onPersistent/#handlers-that-trigger-handlers), so the schedule could never run. |
| `bad_request` | `handlerName` is empty or over 128 characters, or `handlerHash` isn't a SHA-256 hex digest. |
| `bad_request` | `handlerName` is empty or over 128 characters, `handlerHash` isn't a SHA-256 hex digest, or `handlerScope` isn't `'app'` or `'user'`. |
| `events_handler_not_found` | Nothing is published under `handlerName` in that scope. The schedule isn't created. |
| `events_handler_hash_mismatch` | The published handler isn't the source you passed as `handler`. |
| `events_schedule_limit` | The user holds as many schedules as their plan allows, for your app or in total. |
@@ -265,6 +269,8 @@ The promise rejects with `{ message, code }`:
| `events_disabled` | Schedules aren't available on this server, as on self-hosted servers. |
| `events_failed` | The server sent a response the SDK couldn't read. |
With a `{ file }` handler, errors from [`puter.fs.read()`](/FS/read/) also pass through.
## Examples
The handler is published once, by the app's developer, from a deploy script or the browser console. The schedule is created from the app, for each user.
@@ -311,6 +317,9 @@ const reminder = await puter.events.onSchedule({
context: { todoId: 'todo-42' },
});
// Move it to two hours from now
await puter.events.updateSchedule(reminder.scheduleId, { at: Date.now() + 2 * 60 * 60 * 1000 });
// Changed your mind
await reminder.off();
```
+2 -2
View File
@@ -10,7 +10,7 @@ Ends a schedule created with [`puter.events.onSchedule()`](/Events/onSchedule/).
It also cancels a notification your app stored to send later with [`puter.notifications.notify()`](/Notifications/notify/#send-it-later), the same as [`puter.notifications.cancel()`](/Notifications/cancel/).
The `off()` method on the schedule [`onSchedule()`](/Events/onSchedule/) returns does the same thing.
The `off()` method on the schedule [`onSchedule()`](/Events/onSchedule/) returns does the same thing. To move a schedule to another time instead of ending it, use [`updateSchedule()`](/Events/updateSchedule/).
On a website with nobody signed in, it asks the user to sign in first, as other Puter.js calls do. An app running on Puter is always signed in.
@@ -37,7 +37,7 @@ It rejects with `{ message, code }`:
| `code` | Meaning |
| --- | --- |
| `events_schedule_not_found` | No such schedule, it isn't this caller's, or it already ended. Also for an empty `scheduleId`. |
| `events_schedule_sending` | A notification stored to send later has started to go out, so it can't be cancelled. |
| `events_schedule_sending` | A stored notification is sending. If it returns to `active` after a failed attempt, it can be cancelled while it waits to retry. |
| `auth_canceled` | Nobody was signed in, and the user closed the sign-in without finishing it. |
| `too_many_requests` | Over the remove rate limit, which is shared with [`puter.events.unsubscribe()`](/Events/unsubscribe/). |
| `events_disabled` | Schedules aren't available on this server, as on self-hosted servers. |
+2 -2
View File
@@ -174,7 +174,7 @@ A key-value change carries `key` instead of `uid` and `path`, and different ops:
| `ts` | Number | As above. |
| `seq` | Number | As above. |
A notification carries the fields listed on [`fetch()`](/Events/fetch/#return-value).
A posted notification carries the fields listed on [`fetch()`](/Events/fetch/#return-value). An app message's live dismissal carries `op: 'dismiss'` and its `uid`, with no `notification` content. Dismissals follow the same audience rules. The notification SDK exposes them through [`on('dismiss')`](/Notifications/on/).
A [schedule](/Events/onSchedule/) has no subject you can subscribe to. Its runs carry `op: 'tick'` and the fields listed on [`onSchedule()`](/Events/onSchedule/#the-event).
@@ -182,7 +182,7 @@ Events never say who made a change. On a shared folder, that would tell every su
[`puter.kv.flush()`](/KV/flush/) delivers nothing, since the keys a flush can list aren't reliably the keys it removed.
Writes to the same subject within 250 ms arrive as one event carrying the latest state, so a multipart upload or a save loop is one delivery rather than one per write. All of an app's notifications share one subject, so notifications sent within 250 ms of each other arrive as the last of them. [`fetch()`](/Events/fetch/) still returns every one.
Writes to the same subject within 250 ms arrive as one event carrying the latest state, so a multipart upload or a save loop is one delivery rather than one per write. All of an app's notifications share one subject, so notifications sent within 250 ms of each other arrive as the last of them. Dismissals are coalesced separately for each notification id. [`fetch()`](/Events/fetch/) still returns every one.
### Gaps
+3 -3
View File
@@ -6,7 +6,7 @@ platforms: [websites, apps, nodejs, workers]
<div class="info">The Events API is in beta. Event shapes, limits, and behavior may change between releases.</div>
Changes the `context` of a subscription created with [`puter.events.onPersistent()`](/Events/onPersistent/). The subscription keeps its `subId`, its handler, and any events it hasn't delivered yet, and every delivery after the call runs with the new `context` as `ctx`. Ending the subscription and subscribing again would give it a new `subId` and drop its undelivered events.
Changes the `context` of a subscription created with [`puter.events.onPersistent()`](/Events/onPersistent/). The subscription keeps its `subId`, its handler, and any events it hasn't delivered yet, and later deliveries in the events worker run with the new `context` as `ctx`. Ending the subscription and subscribing again would give it a new `subId` and drop its undelivered events. To change a [schedule's](/Events/onSchedule/) context, use [`updateSchedule()`](/Events/updateSchedule/).
An app can change only the subscriptions it created. Outside an app, you can change any of yours. A scoped access token, such as the one in a [`getReadURL()`](/FS/getReadURL/) URL, can't change any.
@@ -29,14 +29,14 @@ The new context. It replaces the old one completely and is evaluated once, at th
A `Promise` that resolves to the updated subscription, in the same shape [`puter.events.list()`](/Events/list/) returns. Its `contextKeys` and `contextHash` describe the new context, never its values.
A delivery already running when you call `update()` finishes with the old context. A suspended subscription stays suspended and uses the new context once it resumes. If this page runs a function `handler` for the subscription, that handler gets the new context too.
A delivery already running when you call `update()` finishes with the old context. A suspended subscription stays suspended and uses the new context once it resumes. If this page runs a function `handler` for the subscription, that handler gets the new context too. Handlers in other open pages keep the context they registered with.
The promise rejects with `{ message, code }`:
| `code` | Meaning |
| --- | --- |
| `subscription_does_not_exist` | No such subscription, or it isn't yours to change. |
| `events_schedule_immutable` | The subscription belongs to a [schedule](/Events/onSchedule/), and a schedule's `context` can't be changed. Remove the schedule and create a new one. |
| `events_schedule_immutable` | The subscription belongs to a [schedule](/Events/onSchedule/). Change its context or timing with [`updateSchedule()`](/Events/updateSchedule/). |
| `invalid_request` | `context` is missing. |
| `events_context_too_large` | `context` is over 4 KB serialized. |
| `events_context_invalid` | `context` can't be serialized as JSON. |
+145
View File
@@ -0,0 +1,145 @@
---
title: puter.events.updateSchedule()
description: Move a schedule to a new time, change its context, or edit a notification stored to send later, while keeping its id.
platforms: [websites, apps, nodejs, workers]
---
<div class="info">The Events API is in beta. Event shapes, limits, and behavior may change between releases.</div>
Changes a schedule created with [`puter.events.onSchedule()`](/Events/onSchedule/), or a notification stored to send later with [`puter.notifications.notify()`](/Notifications/notify/#send-it-later). The schedule keeps its `scheduleId`, its kind and its handler, so ids you saved elsewhere stay valid.
An app can change only the schedules it created. An account session can change any of the user's schedules. A scoped access token can't change any. On a website with nobody signed in, the call asks the user to sign in first, as other Puter.js calls do.
## Syntax
```js
puter.events.updateSchedule(scheduleId, changes)
```
## Parameters
#### `scheduleId` (String) (required)
The `scheduleId` from [`onSchedule()`](/Events/onSchedule/), [`notify()`](/Notifications/notify/#send-it-later) or [`listSchedules()`](/Events/listSchedules/).
#### `changes` (Object) (required)
For a schedule from [`onSchedule()`](/Events/onSchedule/), pass new timing, a new `context`, or both:
- **Timing.** Any of `at`, `every`, `cron`, `tz`, `startAt`, `expiresAt` and `maxRuns` replaces the whole timing, so pass exactly one of `at`, `every` or `cron`, together with every other timing option you want to keep. They take the same values as in [`onSchedule()`](/Events/onSchedule/#parameters). A recurring schedule can switch between `every` and `cron`, and an `at` schedule stays a one-time schedule. Without timing options, the timing stays as it is.
- `context` (Object | null): Replaces the whole context. Pass `null` to clear it. Up to 4 KB serialized, as in [`onSchedule()`](/Events/onSchedule/#parameters).
For a stored notification, pass a new `sendAt`, new content, or both:
- `sendAt` (Number | String | Date): The new time to send it, as in [`notify()`](/Notifications/notify/#parameters). A time within 5 seconds, or in the past, sends it now, with the [retries](/Notifications/notify/#send-it-later) of any stored notification.
- `title`, `text`, `icon`, `target`, `data`, `group`, `channel`, `sensitive`, `priority`, `ttl`: Replace the stored values, with the same rules as [`notify()`](/Notifications/notify/#parameters). Fields you leave out keep their values. Pass `null` to clear an optional field. The `title` can't be cleared.
The handler and the recipient can't change. Passing `handler`, `handlerName`, `handlerScope`, `handlerHash`, `handlerSource` or `to` rejects with `invalid_request`. To run different code, remove the schedule and create a new one.
## Return value
A `Promise` that resolves to the updated schedule, in the shape [`getSchedule()`](/Events/getSchedule/#return-value) returns. Its `nextRunAt` is the next run under the new timing. It describes the context by its key names and a hash, and never includes a stored notification's content. A stored notification whose new `sendAt` is due now is sent during the call, so the schedule can come back already ended.
The promise rejects with `{ message, code }`:
| `code` | Meaning |
| --- | --- |
| `events_schedule_not_found` | No such schedule, it isn't this caller's, or it already ended. Also for an empty `scheduleId`. |
| `events_schedule_sending` | The stored notification has started to go out. If the attempt fails and it returns to `active`, it can be changed while it waits to retry. |
| `invalid_request` | `changes` isn't an object, it has a field that can't change, or the new timing would turn a one-time schedule into a recurring one or the other way round. |
| `events_schedule_invalid` | A timing option or `sendAt` is malformed, or too far ahead. |
| `events_schedule_never_fires` | The new timing would never run. |
| `events_schedule_too_frequent` | The new timing would run more often than the user's plan allows. |
| `events_schedule_limit` | The new timing goes over the number of schedules the user's plan allows. |
| `events_schedule_budget` | The new timing goes over the runs per hour the user's plan allows. |
| `events_schedule_busy` | Puter is checking too many cron expressions at once. Try again in a moment. |
| `events_context_invalid` | `context` can't be serialized as JSON. |
| `events_context_too_large` | The serialized `context` is over 4 KB. |
| `bad_request` | A notification field is over its limit or has the wrong type. The message names the field. |
| `notification_value_too_large`, `events_value_too_large` | The stored notification would be over 16 KB as JSON. |
| `too_many_requests` | Over the rate limit, which is shared with [`onSchedule()`](/Events/onSchedule/). See [Rate Limits and Quotas](/rate-limits-and-quotas/#schedules). |
| `auth_canceled` | Nobody was signed in, and the user closed the sign-in without finishing it. |
| `events_disabled` | Changing schedules isn't available on this server. |
| `events_failed` | Other changes to the schedule kept getting in the way, or the server sent a response the SDK couldn't read. |
## How the change applies
- Runs after the call use the new timing and context. A run that already started finishes with the old context, and its result doesn't affect the new timing.
- A suspended schedule stays suspended, and uses the new timing and context when it resumes.
- New timing is checked against the user's current plan, as a new schedule would be, except that the schedule being changed doesn't count against itself. See [Rate Limits and Quotas](/rate-limits-and-quotas/#schedules).
- If the call fails partway, for example with a network error, the change may still have happened, and the schedule can pause for a minute or more before Puter finishes it. Read the schedule with [`getSchedule()`](/Events/getSchedule/) to check.
## Examples
<strong class="example-title">Move a reminder to a later time</strong>
```html;events-update-reminder
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
// (1) Store a reminder for an hour from now
const reminder = await puter.notifications.notify({
title: 'Review your notes',
sendAt: Date.now() + 60 * 60 * 1000,
});
if (typeof reminder !== 'object' || !reminder) return; // due now, so it was sent at once
puter.print(`goes out ${new Date(reminder.sendAt).toLocaleTimeString()}<br>`);
// (2) Move it an hour later and change what it says, keeping its id
const updated = await puter.events.updateSchedule(reminder.scheduleId, {
sendAt: Date.now() + 2 * 60 * 60 * 1000,
title: 'Review your meeting notes',
});
puter.print(`now goes out ${new Date(updated.nextRunAt).toLocaleTimeString()}<br>`);
// (3) Cleanup
await puter.notifications.cancel(reminder.scheduleId);
puter.print('cancelled<br>');
})();
</script>
</body>
</html>
```
<strong class="example-title">Move a weekday schedule to another time</strong>
```html;events-update-cron
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
if (!await puter.perms.request('backgroundEvents')) {
puter.print('background events were not allowed<br>');
return;
}
// (1) Weekdays at 09:00 in this browser's time zone
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const schedule = await puter.events.onSchedule({
cron: '0 9 * * 1-5',
tz,
handler: async ({ user }) => {
await user.notifications.notify({ title: 'Check your tasks' });
},
});
puter.print(`next run: ${new Date(schedule.nextRunAt).toLocaleString()}<br>`);
// (2) Move it to 10:00, keeping the same id and handler
const updated = await puter.events.updateSchedule(schedule.scheduleId, {
cron: '0 10 * * 1-5',
tz,
});
puter.print(`next run: ${new Date(updated.nextRunAt).toLocaleString()}<br>`);
// (3) Cleanup: end the schedule, then remove the handler it created
await schedule.off();
await puter.events.handlers.remove(schedule.handlerName, { scope: 'user' });
puter.print('removed<br>');
})();
</script>
</body>
</html>
```
+12 -1
View File
@@ -31,6 +31,8 @@ A notification has a `title`, and optionally `text` and an `icon`. A `target` pa
To send a notification at a set time instead of now, pass `sendAt` to [`notify()`](/Notifications/notify/#send-it-later). It goes out then, even when your app is closed.
When the user reads a notification in your app, [`dismiss()`](/Notifications/dismiss/) closes it on their other devices too.
## Who you can notify
A notification goes to the user your app runs for. To notify another user, such as a chat member, share data with them in a [key-value share](/KV/share/) made with `notify: true`, and send through the share's handle. Each side of the share can notify the other.
@@ -136,10 +138,12 @@ Your app's notifications are events on the `notif:app-user` [subject](/Events/su
## Functions
- **[`puter.notifications.notify()`](/Notifications/notify/)** - Send a notification to the user, or to the other side of a key-value share
- **[`puter.notifications.markShown()`](/Notifications/markShown/)** - Mark a notification as shown, so the next one in its group is new
- **[`puter.notifications.dismiss()`](/Notifications/dismiss/)** - Dismiss a notification and close it on the user's other devices
- **[`puter.notifications.cancel()`](/Notifications/cancel/)** - Cancel a notification stored to send later
- **[`puter.notifications.onNotification()`](/Notifications/onNotification/)** - Receive your app's notifications, including the ones sent while it was closed
- **[`puter.notifications.onPush()`](/Notifications/onPush/)** - Say what a push shows for one channel, and handle its notifications while the page is open
- **[`puter.notifications.on('click')`](/Notifications/on/)** - React when the user clicks one of your notifications
- **[`puter.notifications.on()`](/Notifications/on/)** - React when one of your notifications is clicked or dismissed
- **[`puter.notifications.enable()`](/Notifications/enable/)** - Turn on pushes for your app in this browser
- **[`puter.notifications.disable()`](/Notifications/disable/)** - Turn off pushes for your app
- **[`puter.notifications.status()`](/Notifications/status/)** - Check whether pushes reach this browser, without asking
@@ -156,6 +160,8 @@ For Puter's own apps, which manage where pushes go:
- [Send a Notification](/playground/notifications-notify/)
- [Send a Notification Later](/playground/notifications-send-later/)
- [Mark a Notification as Shown](/playground/notifications-mark-shown/)
- [Dismiss a Notification](/playground/notifications-dismiss/)
- [Push notifications for a website](/Notifications/push/#a-minimal-page)
## Recipes
@@ -164,3 +170,8 @@ For Puter's own apps, which manage where pushes go:
- [Notify Chat Members of New Messages](/recipes/notify-chat-members-of-new-messages/)
- [Alert Users When Shared Data Changes](/recipes/alert-users-when-shared-data-changes/)
- [Catch Up on Notifications](/recipes/catch-up-on-notifications/)
- [Mark Notifications as Read](/recipes/mark-notifications-as-read/)
- [Add Buttons to Push Notifications](/recipes/add-buttons-to-push-notifications/)
- [Notify Users of Incoming Calls](/recipes/notify-users-of-incoming-calls/)
- [Show Unread Counts on Your App Icon](/recipes/show-unread-counts-on-your-app-icon/)
- [Remind Users Later](/recipes/remind-users-later/)
+3 -1
View File
@@ -8,7 +8,9 @@ Cancels a notification your app stored to send later with [`notify()`](/Notifica
It does the same thing as [`puter.events.removeSchedule()`](/Events/removeSchedule/), and shares its rate limit with it. The cancelled notification stays in [`puter.events.listSchedules()`](/Events/listSchedules/) for 7 days, with `state: 'ended'` and `endReason: 'removed'`.
A notification that has started to go out can't be cancelled. Once it's sent, it's in the user's notifications like any other.
A notification in `sending` can't be cancelled. If a send fails before the notification is written, it can return to `active` and be cancelled while it waits to retry. Once it's sent, it's in the user's notifications like any other.
To move it to another time or change what it says instead, use [`puter.events.updateSchedule()`](/Events/updateSchedule/).
## Syntax
```js
+76
View File
@@ -0,0 +1,76 @@
---
title: puter.notifications.dismiss()
description: Dismiss one of your app's notifications, and close it on the user's other devices.
platforms: [websites, apps]
---
Marks one of your app's notifications as dismissed, such as when the user reads the message it's about. Every open page of your app hears about it through [`on('dismiss')`](/Notifications/on/), on any of the user's devices, and the notification closes there.
After you dismiss the newest notification in a [group](/Notifications/notify/#groups), the next send with that group creates a new notification instead of replacing it. To get that without closing anything, use [`markShown()`](/Notifications/markShown/).
## Closing it on other devices
On a website, the notification closes on each device where a page of your app has a live notification listener, meaning an [`onNotification()`](/Notifications/onNotification/), [`onPush()`](/Notifications/onPush/) or [`on('dismiss')`](/Notifications/on/) listener. The page that calls `dismiss()` closes its own copy when the call resolves, even when it resolves `false`.
On a device with no such page open, the notification stays on screen until a page of your app starts one of those listeners. The page then checks up to 50 notifications on screen and closes the ones already dismissed. It does the same check when its connection comes back after a drop. These checks don't call your [`on('dismiss')`](/Notifications/on/) handlers.
Puter doesn't send a push only to close a notification, since browsers show a notification for every push. For a push Puter shows, such as one for an app inside the Puter desktop, an open Puter desktop on that device closes it.
Dismissals reach other pages through [Events](/Events/), so a server without events doesn't deliver them.
## Syntax
```js
puter.notifications.dismiss(uid)
```
## Parameters
#### `uid` (String) (required)
The notification's `uid`. For an immediate notification to your app's own user, [`notify()`](/Notifications/notify/) resolves with it. Notifications delivered to [`onNotification()`](/Notifications/onNotification/) and [`on('click')`](/Notifications/on/) also carry it.
## Return value
A `Promise` that resolves to `true` when the notification is dismissed. It resolves to `false` when it was already dismissed, doesn't exist, or isn't one of your app's notifications for this user.
It rejects with an error carrying `message` and `code`:
| `code` | Meaning |
| --- | --- |
| `invalid_argument` | `uid` isn't a non-empty string. |
| `too_many_requests` | Too many notification calls in a short time. Try again later. |
| `auth_canceled` | Nobody was signed in, and the user closed the sign-in without finishing it. |
| `notifications_failed` | The server sent a response the SDK couldn't read. |
## Examples
<strong class="example-title">Dismiss a notification and hear about it</strong>
```html;notifications-dismiss
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
// (1) Every open page of the app hears about dismissals, on any device
puter.notifications.on('dismiss', ({ uid }) => {
puter.print(`dismissed ${uid}<br>`);
});
// (2) Send one, then dismiss it
const uid = await puter.notifications.notify({ title: 'New message from Ana' });
const dismissed = await puter.notifications.dismiss(uid);
puter.print(`dismiss() resolved ${dismissed}<br>`);
})();
</script>
</body>
</html>
```
<strong class="example-title">Dismiss a notification once the user opens it</strong>
```js
puter.notifications.on('click', async ({ uid, target }) => {
await puter.notifications.dismiss(uid);
location.assign(target ?? '/');
});
```
+7
View File
@@ -10,6 +10,8 @@ On a website, the browser asks on your own origin, and pushes arrive through you
The call resolves as soon as the browser has answered and Puter has its subscription. It doesn't wait for the confirmation. Pushes start once your page hands the confirmation back, usually within seconds. If the confirmation doesn't arrive within 30 seconds, the `onError` of [`onPush()`](/Notifications/onPush/) gets `push_verification_timeout`, or the console does without one, and the next page load tries again.
With only the browser's permission prompt, a user can turn pushes on in up to 5 browsers for your app. With permission granted through Puter's prompt, the limit is 10. Turning them on past the limit removes the least recently seen browser. See [push limits](/rate-limits-and-quotas/#notifications).
The choice sticks. Once pushes are on, [`onPush()`](/Notifications/onPush/) keeps the subscription current on later page loads without asking again. After [`disable()`](/Notifications/disable/), after the user turns your app off or removes this browser in Puter, after [`puter.auth.signOut()`](/Auth/signOut/), or after a different user signs in, page loads don't subscribe again until your page calls [`enable()`](/Notifications/enable/) from a click.
If the user turned your app off in Puter's settings, the call rejects with `push_revoked`. To ask again, call [`requestPermission()`](/Notifications/requestPermission/) from a click, which opens Puter's permission prompt, and then call [`enable()`](/Notifications/enable/) again.
@@ -42,6 +44,7 @@ It rejects with an error carrying `message` and `code`:
| `push_unavailable` | This server has no push notification service, as on most self-hosted servers. |
| `push_disabled` | This server has push notifications turned off. |
| `push_revoked` | The user turned your app off in Puter's settings. Call [`requestPermission()`](/Notifications/requestPermission/) from a click, then call [`enable()`](/Notifications/enable/) again. |
| `push_device_removed` | The user removed this browser in Puter, and the fresh subscription was also refused. |
| `push_worker_unavailable` | The service worker couldn't be registered or didn't start, for example because nothing is served at its path. |
| `push_worker_conflict` | Another service worker controls the scope. Add the `importScripts()` line to that worker and pass its path as `serviceWorker`. |
| `push_subscription_conflict` | The worker already has a push subscription from another service, such as Firebase or OneSignal. Give Puter its own worker in a folder, such as `/puter/puter-sw.js`, and pass that path as `serviceWorker`. A subscription Puter made earlier, even with a key Puter has since changed, is replaced instead. |
@@ -50,8 +53,12 @@ It rejects with an error carrying `message` and `code`:
| `push_endpoint_revoked` | Another Puter account turned your app off on this browser's subscription, and a fresh subscription couldn't replace it. |
| `push_challenge_failed` | Puter couldn't reach the browser's push service to send the confirmation. Try again later. |
| `push_verification_canceled` | [`disable()`](/Notifications/disable/) was called before this call finished. |
| `bad_request` | The browser's subscription isn't in a format or from a push service this server accepts. |
| `forbidden` | The call isn't from a signed-in browser session of the app. |
| `origin_mismatch` | The request's origin doesn't belong to the app making the call. |
| `too_many_requests` | Pushes were turned on too many times in a short time. Try again later. |
| `auth_canceled` | Nobody was signed in, and the user closed the sign-in without finishing it. |
| `notifications_failed` | The server sent a response the SDK couldn't read. |
## Examples
+58
View File
@@ -0,0 +1,58 @@
---
title: puter.notifications.markShown()
description: Mark one of your app's notifications as shown, so the next one in its group arrives as a new notification.
platforms: [websites, apps]
---
Marks one of your app's notifications as shown, such as when your app displayed it in its own list.
After you mark the newest notification in a [group](/Notifications/notify/#groups) as shown, the next send with that group creates a new notification instead of replacing it. A notification already on screen stays there. To close it on the user's devices too, use [`dismiss()`](/Notifications/dismiss/).
## Syntax
```js
puter.notifications.markShown(uid)
```
## Parameters
#### `uid` (String) (required)
The notification's `uid`. For an immediate notification to your app's own user, [`notify()`](/Notifications/notify/) resolves with it. Notifications delivered to [`onNotification()`](/Notifications/onNotification/) and [`on('click')`](/Notifications/on/) also carry it.
## Return value
A `Promise` that resolves to `true` when the notification is marked shown. It resolves to `false` when it was already marked shown, doesn't exist, or isn't one of your app's notifications for this user.
It rejects with an error carrying `message` and `code`:
| `code` | Meaning |
| --- | --- |
| `invalid_argument` | `uid` isn't a non-empty string. |
| `too_many_requests` | Too many notification calls in a short time. See [Rate Limits and Quotas](/rate-limits-and-quotas/#notifications). |
| `auth_canceled` | Nobody was signed in, and the user closed the sign-in without finishing it. |
| `notifications_failed` | The server sent a response the SDK couldn't read. |
## Examples
<strong class="example-title">Start a new notification in a group</strong>
```html;notifications-mark-shown
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const first = await puter.notifications.notify({ title: '1 new message', group: 'inbox' });
// (1) Unshown, so the next one in the group replaces it and keeps its uid
const second = await puter.notifications.notify({ title: '2 new messages', group: 'inbox' });
puter.print(`replaced: ${second === first}<br>`);
// (2) Marked shown, so the next one in the group is a new notification
await puter.notifications.markShown(second);
const third = await puter.notifications.notify({ title: '1 new message', group: 'inbox' });
puter.print(`new notification: ${third !== second}<br>`);
})();
</script>
</body>
</html>
```
+24 -5
View File
@@ -27,7 +27,7 @@ puter.notifications.notify(notification)
- `group` (String): A later notification with the same `group` replaces this one, as [Groups](#groups) describes. Up to 128 letters, digits and `. _ : @ -`.
- `channel` (String): What kind of notification it is, such as `messages` or `reminders`. The [`onPush()`](/Notifications/onPush/) handler registered with this key decides what its push shows. Up to 32 lowercase letters, digits and `-`.
- `sensitive` (Boolean): Pushes, and the notification a page of your app shows itself, name only your app, such as "New notification from Chat" for an app named Chat, instead of showing the title and text. On a website, a sensitive push carries no `channel`, `target` or `data`. So no [`onPush()`](/Notifications/onPush/) handler runs for it, a tap opens your site's root, or your service worker's folder when your pages are inside it, and [`on('click')`](/Notifications/on/) gets `null` for `target` and `data`. A listener added with [`onNotification()`](/Notifications/onNotification/) still gets the whole notification.
- `priority` (String): Only `'normal'` is available to apps.
- `priority` (String): `'normal'` (default) or `'high'`. A high-priority push is delivered right away, even to a phone or computer that's idle, so use it for things like an incoming call. Pair it with a short `ttl` so a missed call doesn't ring late. Each user gets at most 60 high-priority pushes an hour from your app, and pushes past that go out at normal priority. Priority doesn't change how the notification looks. To keep it on screen until the user acts, return `requireInteraction` from your [`onPush()`](/Notifications/onPush/) handler. [Notify Users of Incoming Calls](/recipes/notify-users-of-incoming-calls/) shows the whole setup.
- `ttl` (Number): How many seconds a push may wait for a device that's offline, from 1 to 86400. Defaults to 3600. It doesn't change how long the notification is kept.
- `to` (Object): Send it to the other side of a key-value share instead, as `{ handle }` with the share's handle. See [Notify the other side of a share](#notify-the-other-side-of-a-share).
- `sendAt` (Number | String | Date): Send it at this time instead of now, as a `Date`, an ISO-8601 string with a zone, a date alone (midnight UTC), or whole milliseconds since the epoch. Rounded up to the next whole second, and at most 366 days ahead. A time within 5 seconds, or in the past, sends it now. See [Send it later](#send-it-later).
@@ -44,6 +44,10 @@ It rejects with an error carrying `message` and `code`:
| `code` | Meaning |
| --- | --- |
| `events_value_too_large` | With `sendAt`, the stored notification is over 16 KB as JSON, or a field is longer than can be stored. |
| `internal_error` | The notification couldn't be written. A retry may send it again. |
| `notification_value_too_large` | The full notification value is over 16 KB as JSON. |
| `notifications_failed` | The server didn't return the notification's uid. |
| `invalid_argument` | `notification` isn't an object, or its `title` is missing or blank. |
| `bad_request` | A field is over its limit, has the wrong type, or isn't one of the fields above. The message names the field. With `to`, it must be an object with only a string `handle`. |
| `forbidden` | The call wasn't made by an app, for example from Node.js with an account token. |
@@ -84,18 +88,18 @@ const later = await puter.notifications.notify({
if (typeof later === 'object' && later) await puter.notifications.cancel(later.scheduleId);
```
A stored notification is a schedule of `kind: 'deferred'`. You can read it with [`puter.events.getSchedule()`](/Events/getSchedule/) and [`puter.events.listSchedules()`](/Events/listSchedules/), which show `nextRunAt` set to its `sendAt` until it starts to go out. It needs no handler and no `events:background` permission.
A stored notification is a schedule of `kind: 'deferred'`. You can read it with [`puter.events.getSchedule()`](/Events/getSchedule/) and [`puter.events.listSchedules()`](/Events/listSchedules/), which show `nextRunAt` set to its `sendAt` until it starts to go out. You can change it with [`puter.events.updateSchedule()`](/Events/updateSchedule/). It needs no handler and no `events:background` permission.
When it's due:
- It usually goes out within 15 seconds of `sendAt`, as if [`notify()`](/Notifications/notify/) were called then. It reaches every open page of your app live, and goes out as a push when no page answers, as described in [Push notifications for your app](/Notifications/push/#when-a-notification-is-pushed). A `group` replaces whatever notification is open in that group at that time.
- The notification limits are counted then, against the recipient. With `to`, the share is checked when you call [`notify()`](/Notifications/notify/), and again when it goes out, which is when it counts toward the limit for that sender, recipient and app. A push is billed to the user who receives it, as any push is.
- If it can't go out because of a rate limit or a temporary error, it's retried, up to 5 attempts in all. After that it's dropped. A share that stopped, a recipient who blocked you, or an app or account that was deleted drops it right away.
- A temporary error is retried, up to 5 attempts in all, then dropped. A rate-limited send waits at least a minute and tries again without using an attempt, and is dropped once it's over 6 hours late. A share that stopped, a recipient who blocked you, or an app or account that was deleted drops it right away.
- While the sending account is suspended, it waits, and it's tried again every 15 minutes.
It's written once, even when sending is retried. A retry doesn't write a second notification, and doesn't push it or bill it a second time. An open page of your app can receive it twice, with the same `uid`. A retry delivers it to open pages again only within 24 hours of the first attempt. After that, the retry finishes without delivering it.
Once it has gone out, or been dropped, its schedule has `state: 'ended'` and an `endReason` of `delivered` or `dropped`, and it stays in [`puter.events.listSchedules()`](/Events/listSchedules/) for 7 days. While it's going out it's `sending`, and [`cancel()`](/Notifications/cancel/) rejects with `events_schedule_sending`.
Once it has gone out, or been dropped, its schedule has `state: 'ended'` and an `endReason` of `delivered` or `dropped`, and it stays in [`puter.events.listSchedules()`](/Events/listSchedules/) for 7 days. While it's going out it's `sending`, and [`cancel()`](/Notifications/cancel/) rejects with `events_schedule_sending`. If a send fails before the notification is written, it can return to `active` and be cancelled while it waits to retry.
When the user uninstalls your app, the notifications it has waiting for them are cancelled, and one that's due after the uninstall is dropped. Deleting the app, or the account that owns it, cancels all of them. This holds even on a server where sending later is turned off.
@@ -105,7 +109,9 @@ The number of notifications and [`at` schedules](/Events/onSchedule/) your app c
A notification with a `group` replaces the newest notification your app sent the same user with that `group`, as long as that one hasn't been shown or dismissed. The replacement keeps that notification's `uid` and takes the new content and time. Its push replaces the earlier one on the device and alerts the user again. A listener added with [`onNotification()`](/Notifications/onNotification/) gets the replacement too, with the same `uid` and `replaced: true`.
When the earlier notification was shown or dismissed, the call creates a new notification instead, and so does a call without a `group`. A tap on a push that Puter shows, such as one for an app inside the Puter desktop, dismisses its notification. Puter.js has no call that marks a notification shown or dismissed, so a group usually stays one notification that each new one replaces.
When the earlier notification was shown or dismissed, the call creates a new notification instead, and so does a call without a `group`. A tap on a push that Puter shows, such as one for an app inside the Puter desktop, dismisses its notification.
To make the next send with that group a new notification, mark the newest one shown with [`markShown()`](/Notifications/markShown/), or dismiss it with [`dismiss()`](/Notifications/dismiss/), which also closes it on the user's devices.
A replacement counts against the 30 notifications a minute but not the 1,000 a day. When it goes out as a push, it counts against the push limits like any other push.
@@ -156,3 +162,16 @@ await puter.notifications.notify({
target: '/results',
});
```
<strong class="example-title">Ring for an incoming call</strong>
```js
await puter.notifications.notify({
title: 'Ana is calling',
channel: 'calls',
group: 'call:ana',
priority: 'high',
ttl: 30,
data: { callId: 'call_123' },
});
```
+38 -5
View File
@@ -1,10 +1,12 @@
---
title: puter.notifications.on('click')
description: React when the user clicks one of your app's push notifications.
title: puter.notifications.on()
description: React when a notification is clicked or dismissed.
platforms: [websites, apps]
---
Calls `handler` when the user clicks one of your app's notifications, so you can open the screen it's about.
Calls `handler` when one of your app's notifications is clicked or dismissed.
For `click`, the handler can open the screen the notification is about.
Clicks reach your app in two ways:
@@ -13,23 +15,35 @@ Clicks reach your app in two ways:
A click that arrives before your first `click` handler is added, such as the one that opened your app, goes to that first handler, once. Handlers added after it don't get it, so add your handler early during startup. On a website, a page a tap opened drops the click when no `click` handler is added within 10 seconds of the page loading.
To remove a handler, call [`puter.notifications.off('click', handler)`](/Notifications/on/).
For `dismiss`, the handler runs when one of your app's notifications is dismissed with [`dismiss()`](/Notifications/dismiss/), on this page or on any page of your app on any of the user's devices. Use it to remove the notification from your app's own list. Adding a `dismiss` handler starts a live notification listener, which closes the dismissed notification on this device as [Closing it on other devices](/Notifications/dismiss/#closing-it-on-other-devices) describes. On a website with nobody signed in, the handler starts once the user signs in.
To remove a handler, call [`puter.notifications.off(eventName, handler)`](/Notifications/on/).
## Syntax
```js
puter.notifications.on('click', handler)
puter.notifications.on('dismiss', handler)
puter.notifications.off('click', handler)
puter.notifications.off('dismiss', handler)
```
## Parameters
#### `eventName` (String) (required)
Either `click` or `dismiss`.
#### `handler` (Function) (required)
Called with:
For `click`, called with an object with:
- `uid` (String): The notification's id.
- `action` (String | null): The id of the button the user pressed, from the `actions` your [`onPush()`](/Notifications/onPush/) handler returned. It's `null` for a tap on the notification body, which is all a browser without buttons offers.
- `target` (String | null): The `target` the notification was sent with. On a website, it's `null` for a tap on a [sensitive](/Notifications/notify/#parameters) notification.
- `data` (any): The `data` it was sent with, or `null`. On a website, it's also `null` for a tap on a sensitive notification.
For `dismiss`, called with an object with:
- `uid` (String): The dismissed notification's id.
## Return value
It returns `puter.notifications`, so calls can be chained.
@@ -51,3 +65,22 @@ It returns `puter.notifications`, so calls can be chained.
</body>
</html>
```
<strong class="example-title">Handle a button press</strong>
```js
puter.notifications.on('click', async ({ uid, action, data }) => {
if (action === 'accept') await puter.kv.set(`rsvp:${data.eventId}`, 'yes');
await puter.notifications.dismiss(uid);
});
```
[Add Buttons to Push Notifications](/recipes/add-buttons-to-push-notifications/) shows the whole setup.
<strong class="example-title">Remove a dismissed notification from your app</strong>
```js
puter.notifications.on('dismiss', ({ uid }) => {
document.querySelector(`[data-notification-id="${CSS.escape(uid)}"]`)?.remove();
});
```
@@ -12,6 +12,8 @@ It runs on [Events](/Events/) and reads the `notif:app-user` [subject](/Events/s
While a page has a listener, it tells Puter that it received each notification, and whether the user is looking at the page. That can keep the notification from going out as a push, as [When a notification is pushed](/Notifications/push/#when-a-notification-is-pushed) describes.
The listener doesn't call `handler` for dismissals. They go to [`on('dismiss')`](/Notifications/on/) handlers, and the listener closes the dismissed notification on this device, as [`dismiss()`](/Notifications/dismiss/#closing-it-on-other-devices) describes.
## Syntax
```js
puter.notifications.onNotification(handler)
+25 -3
View File
@@ -34,13 +34,19 @@ puter.notifications.onPush(key, handler, options)
The `channel` your notifications are sent with. 1 to 32 lowercase letters, digits or `-`.
#### `handler` (Function) (required)
A plain `function` or an arrow function. It's called with the notification, in the same shape [`onNotification()`](/Notifications/onNotification/) delivers. It may return, or resolve to, an object that changes what the browser shows:
A plain `function` or an arrow function. It's called with the notification, in the shape [`onNotification()`](/Notifications/onNotification/) delivers, in the page and in the service worker alike. Its `replaced` field is `true` when the notification replaced an earlier one in its [group](/Notifications/notify/#groups). It may return, or resolve to, an object that changes what the browser shows:
- `title`, `body`, `icon`, `badge`, `image`, `tag` (String): As in the browser's `showNotification()`. A notification sent with a `group` gets a `tag` from it by default, so a later one in the group replaces it on screen.
- `url` (String): A path or URL on your origin. Defaults to the notification's `target`. A relative path is read from your site's root, or from your worker's folder when your pages are inside it ([requirements](/Notifications/push/#requirements)). A URL on another origin is replaced with that root or folder. When no page of your site is open, a tap opens it. When a page is open, the tap focuses that page and goes to [`on('click')`](/Notifications/on/). A page without a `click` handler goes to `url` instead.
| Field | Type | Description |
| --- | --- | --- |
| `title`, `body`, `icon`, `badge`, `image`, `tag` | String | As in the browser's `showNotification()`. A notification sent with a `group` gets a `tag` from it by default, so a later one in the group replaces it on screen. |
| `requireInteraction` | Boolean | Keeps the notification on screen until the user taps or closes it, where the browser supports it, such as for an incoming call. |
| `actions` | Array | Up to two buttons, each `{ action, title, icon? }`. `action` is the id [`on('click')`](/Notifications/on/) gets when the button is pressed, and `title` is its label. Both are required, can't be blank, and can be at most 64 characters (UTF-16 code units). `icon` is an optional image URL. Invalid entries are dropped and the first two valid ones are kept. A press opens or focuses your page, like a tap. Browsers without buttons show only the notification. |
| `url` | String | A path or URL on your origin. Defaults to the notification's `target`. A relative path is read from your site's root, or from your worker's folder when your pages are inside it ([requirements](/Notifications/push/#requirements)). A URL on another origin is replaced with that root or folder. When no page of your site is open, a tap opens it. When a page is open, the tap focuses that page and goes to [`on('click')`](/Notifications/on/). A page without a `click` handler goes to `url` instead. |
Returning nothing shows the notification's own `title` and `text`. Inside the Puter desktop, the result isn't used, and a push shows the notification's own title and text.
[Add Buttons to Push Notifications](/recipes/add-buttons-to-push-notifications/) shows `actions` in use, and [Notify Users of Incoming Calls](/recipes/notify-users-of-incoming-calls/) adds `requireInteraction`.
A [sensitive](/Notifications/notify/#parameters) notification shows only your app's name, whatever the handler returns. Its push carries no `channel`, so no handler runs for it in the worker.
#### `options` (Object) (optional)
@@ -87,6 +93,22 @@ It rejects with an error carrying `message` and `code`:
| `invalid_argument` | `key` isn't 1 to 32 lowercase letters, digits or `-`. |
| `invalid_handler` | `handler` isn't a plain function or an arrow function, for example a method shorthand, a bound function or a class method. |
Errors reported to `onError` can also carry these codes. The push setup errors listed on [`enable()`](/Notifications/enable/#return-value) can arrive here during renewal, except for the quiet failures described under `options.onError` above. Errors thrown by your handler pass through as written.
| `code` | Meaning |
| --- | --- |
| `push_handler_timeout` | The handler ran past 5 seconds in the page. |
| `push_verification_timeout` | The confirmation push didn't arrive within 30 seconds. |
| `push_challenge_expired`, `push_challenge_invalid` | Puter couldn't verify the confirmation handed back by the worker. |
| `app_required` | The live listener wasn't opened as an app. |
| `events_disabled` | This server doesn't have live notification events turned on. |
| `events_subscription_limit` | The live connection has reached its subscription limit. |
| `too_many_requests` | Opening the live listener or reading notifications hit a rate limit. Push renewal rate limits are quiet. |
| `reauth_required`, `auth_changed` | The live listener stopped because the session or signed-in account changed. |
| `events_connection_failed` | The live connection couldn't be restored. |
| `events_failed` | A notification read failed without a readable error code. |
| `catch_up_truncated` | Catch-up after a gap reached its read limit. See [`onNotification()`](/Notifications/onNotification/). |
## Examples
<strong class="example-title">Chat messages</strong>
+36
View File
@@ -63,6 +63,10 @@ The worker follows the browser's service worker rules:
To check the worker, open `https://<your site>/puter-sw.js` in a browser tab. It should show the `importScripts` line as plain text.
#### Sharing an existing service worker
Import Puter's script into your worker and pass its path as `serviceWorker` to [`onPush()`](/Notifications/onPush/), [`enable()`](/Notifications/enable/), [`disable()`](/Notifications/disable/) and [`status()`](/Notifications/status/). Your own `push` and `notificationclick` listeners also receive Puter's events. Have them skip a push whose JSON contains `puter` or `data.puter`, and a click whose `event.notification.data.puter` is set, so they avoid showing or opening it twice. Puter's script leaves other pushes and clicks to your listeners.
#### Serving your own copy
The worker at `https://js.puter.com/push-sw.js` always serves the current version, and Puter's CDN picks up a new one within five minutes. It matches `https://js.puter.com/v2/`, which always serves the newest Puter.js, so a site that loads Puter.js from there should keep the default worker.
@@ -166,6 +170,12 @@ To notify another user, share data with them with `notify: true` and send throug
On a website, a tap on a push, or on a notification a page of your site showed itself, focuses an open page of your site and calls its [`on('click')`](/Notifications/on/) handlers. With no page open, it opens the handler's `url`, or else the notification's `target`, and the new page's first [`on('click')`](/Notifications/on/) handler gets the click. A tap reaches one page, once: when an open page doesn't answer within about half a second, the tap opens its URL instead, and the slow page doesn't get it, unless that page is already at the tap's URL. If that page wasn't loaded through your worker, such as one opened before the worker was installed, the URL opens in a new tab. Inside the Puter desktop, a tap opens your app and hands the click to [`on('click')`](/Notifications/on/).
A push can also carry up to two buttons, which your [`onPush()`](/Notifications/onPush/) handler returns as `actions`. The pressed button's id reaches [`on('click')`](/Notifications/on/) as `action`. [Add Buttons to Push Notifications](/recipes/add-buttons-to-push-notifications/) shows how.
## When the user reads it elsewhere
When the user reads a notification in your app, call [`dismiss()`](/Notifications/dismiss/). The push closes on the user's other devices where a page of your app is open, and on the rest the next time a page of your app opens there. Puter doesn't send a push only to close one. [Closing it on other devices](/Notifications/dismiss/#closing-it-on-other-devices) has the rule.
## Groups and sensitive notifications
A notification with a `group` replaces your earlier one in the same group, on the device and in your app, and alerts the user again. A group can name a chat, a thread or a person, such as `dm:[email protected]`. [`notify()`](/Notifications/notify/#groups) has the rule.
@@ -240,6 +250,26 @@ To turn pushes off, call [`disable()`](/Notifications/disable/). Inside the desk
While the app is open, [`onPush()`](/Notifications/onPush/) and [`onNotification()`](/Notifications/onNotification/) receive its notifications live, the same as on a website. The result of an [`onPush()`](/Notifications/onPush/) handler isn't used inside the desktop.
## App icon badges
An installed web app can show a count on its icon with the browser's [Badging API](https://developer.chrome.com/docs/capabilities/web-apis/badging-api), where the browser supports it. Puter.js has no call for it. Send the count in the notification's `data`, and set it with `navigator.setAppBadge()` in your [`onPush()`](/Notifications/onPush/) handler, which runs in both the page and the service worker:
```js
await puter.notifications.onPush('messages', async (n) => {
const count = n.data?.unreadCount;
if (Number.isSafeInteger(count) && count >= 0) {
try {
await navigator.setAppBadge?.(count);
} catch {
// The browser refused the badge. The notification still shows.
}
}
return { title: n.title, body: n.text ?? '' };
});
```
The `badge` field a handler returns is the URL of a small image on the notification, not a count. [Show Unread Counts on Your App Icon](/recipes/show-unread-counts-on-your-app-icon/) also clears the count when the user reads the messages.
## Where pushes work
Pushes use the browser's Web Push support. Each browser gets its own registration, so a user who wants pushes on their phone and their laptop turns them on in both.
@@ -320,6 +350,12 @@ The browser can be allowed to show notifications while the operating system hide
| No push while your site is open in a tab | The open page received the notification live. It shows one only when it's hidden or its window isn't focused. |
| No push at all | The browser isn't running, the operating system hides its notifications ([above](#operating-system-settings)), the user reached the [push limit](/rate-limits-and-quotas/#notifications) for your app, or the user has no usage credit left. |
## Push delivery
Push delivery is best effort. A push service's `429` or `5xx` response gets at most one retry, if time remains. Timeouts aren't retried. Under heavy load or while a push service is failing, sends can be skipped, and they aren't queued for later. The notification stays available through [`puter.events.fetch()`](/Events/fetch/), and [`notify()`](/Notifications/notify/) can still resolve. See [push limits and timeouts](/rate-limits-and-quotas/#notifications).
A browser is removed when its push service answers `404` or `410`. It can also be removed after 10 failed sends if it hasn't received a push or renewed its registration for over 3 days, or after 60 days without either. A page that calls [`onPush()`](/Notifications/onPush/) renews its registration.
## Good to know
- Say who did what. "Ana shared Q3-report.pdf" reads well as a push. A push that only says there's something new can be hidden as spam on Android.
+5 -5
View File
@@ -4,9 +4,9 @@ description: Check whether push notifications from your app reach the browser th
platforms: [websites, apps]
---
Whether your app's push notifications reach the browser the user is in right now. It never prompts, so call it on page load to decide whether to show your own "Turn on notifications" button, which calls [`enable()`](/Notifications/enable/).
Checks your app's push subscription in this browser. It never prompts, so call it on page load to decide whether to show your own "Turn on notifications" button, which calls [`enable()`](/Notifications/enable/).
On a website, it's `'on'` when the user turned pushes on in this browser with [`enable()`](/Notifications/enable/), this browser still holds the subscription, and Puter still has it. It's `'off'` after the user removes this browser in Puter's settings or turns your app off there. Pushes start once Puter confirms the browser, usually within seconds of [`enable()`](/Notifications/enable/) resolving. Inside the Puter desktop, it's `'on'` when pushes are on in this browser and the user allowed pushes from your app.
On a website, it's `'on'` when the user turned pushes on in this browser with [`enable()`](/Notifications/enable/), this browser still holds the subscription, and Puter hasn't reported it removed or revoked. It's `'off'` after the user removes this browser in Puter's settings or turns your app off there. Pushes start once Puter confirms the browser, usually within seconds of [`enable()`](/Notifications/enable/) resolving. Inside the Puter desktop, it's `'on'` when pushes are on in this browser and the user allowed pushes from your app.
## Syntax
```js
@@ -24,12 +24,12 @@ A `Promise` that resolves to one of these strings. It never rejects.
| Value | Meaning |
| --- | --- |
| `'on'` | Pushes from your app reach this browser. |
| `'off'` | They don't yet. Call [`enable()`](/Notifications/enable/) from a click to turn them on. After the user turned your app off in Puter, [`enable()`](/Notifications/enable/) rejects with `push_revoked`. |
| `'on'` | This browser holds a subscription the user turned on here. Puter hasn't reported it removed or revoked. |
| `'off'` | Pushes aren't on in this browser. Call [`enable()`](/Notifications/enable/) from a click to turn them on. After the user turned your app off in Puter, [`enable()`](/Notifications/enable/) rejects with `push_revoked`. |
| `'blocked'` | The browser blocks notifications, for your site on a website or for Puter inside the desktop. The user unblocks them in the browser's site settings. |
| `'unavailable'` | This browser or environment can't receive pushes, such as a browser without Web Push or Node.js, or the desktop can't tell. |
On a website, it answers `'off'` on a server without a push notification service, where [`enable()`](/Notifications/enable/) then rejects with `push_unavailable`. It also answers `'off'` when nobody is signed in. If it can't reach Puter to check the subscription, it answers from this browser alone.
On a website, it answers `'off'` when nobody is signed in. When it can't check the subscription with Puter, such as when Puter can't be reached or the server has no push notification service, it answers from this browser alone, so it can be `'on'` while pushes don't arrive.
## Examples
+1 -1
View File
@@ -70,7 +70,7 @@ The array form resolves to an array of those values, in the order asked.
The promise rejects with `{ message, code: 'invalid_argument' }` when the details are invalid, before anything is asked. That covers an unknown resource passed with details, a value the resource doesn't accept, a `path` that isn't `~` and doesn't start with `/` or `~/`, a `uid` that isn't a uid, a `'kvShare'` prefix over its length limit, and a `'kvShare'` request from a page that isn't running as an app.
A string passed on its own that names no resource is read as a [permission string](#permission-strings-deprecated-). If it isn't one the user can be asked about, the promise resolves to `false` without a prompt.
A string passed on its own that names no resource is read as a [permission string](#permission-strings-deprecated). If it isn't one the user can be asked about, the promise resolves to `false` without a prompt.
## Creating a path on request
+65 -6
View File
@@ -213,6 +213,18 @@ ul code {
font-size: 14px;
}
/* A table wider than the page scrolls sideways inside this wrapper, added by
the table renderer in build.js. The wrapper takes the table's margin so the
scrollbar sits right under the table. */
.docs-content .table-scroll {
overflow-x: auto;
margin: 24px 0;
}
.docs-content .table-scroll > table {
margin: 0;
}
.docs-content table thead{
background: #f5f6fb;
}
@@ -1620,13 +1632,36 @@ footer .copyright-notice{
/* Table of Contents Styles */
#toc-wrapper {
position: fixed;
height: 100%;
top: 40px;
bottom: 40px;
right: 0;
margin-top: 80px;
}
/* Where both the sidebar and the TOC show, move 24px of width from the
sidebar to the TOC. The content column shifts left by the same 24px, so it
stays centered between them. */
@media (min-width: 1200px) {
#sidebar {
width: 256px;
}
#sidebar-wrapper {
width: calc(25% - 24px);
}
#toc-wrapper {
width: calc(16.66666667% + 24px);
}
}
/* The TOC scrolls inside the wrapper's padding, so its scrollbar sits away
from the page scrollbar. */
.table-of-contents {
max-height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 16px;
margin-right: 8px;
}
.toc-title {
@@ -1639,6 +1674,7 @@ footer .copyright-notice{
}
.toc-nav {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
@@ -1646,13 +1682,36 @@ footer .copyright-notice{
.toc-link {
display: block;
font-size: 13px;
font-size: 14px;
color: #5f5f5f;
text-decoration: none;
line-height: 1.4;
transition: color 0.2s ease;
border-left: 2px solid transparent;
padding-bottom: 4px;
padding: 2px 0 2px 12px;
}
/* The section being read, set by assets/js/toc.js. */
.toc-link.active {
color: #012238;
}
/* The bar beside the active link. assets/js/toc.js moves it to the link and
sets its height, and the transition makes it slide and resize. */
.toc-indicator {
position: absolute;
top: 0;
left: 0;
width: 2px;
background: #012238;
pointer-events: none;
transition: transform 0.25s ease, height 0.25s ease;
}
@media (prefers-reduced-motion: reduce) {
.toc-indicator {
transition: none;
}
}
.toc-link:hover {
@@ -1665,8 +1724,8 @@ footer .copyright-notice{
}
.toc-link[data-level="2"] {
padding-left: 12px;
font-size: 12px;
padding-left: 24px;
font-size: 13px;
}
/* Platform Compatibility Badges */
+1
View File
@@ -7,5 +7,6 @@ import './search.js';
import './context-menu.js';
import './example.js';
import './sidebar.js';
import './toc.js';
window.hljs = hljs;
+109
View File
@@ -0,0 +1,109 @@
import $ from 'jquery';
// Highlight the table-of-contents link for the section being read, so the
// reader can see where they are on the page.
// A heading becomes the current section once its top scrolls above this many
// pixels from the top of the window.
const ACTIVE_OFFSET = 120;
let activeLink = null;
let pendingFrame = null;
function findActiveLink () {
const links = [...document.querySelectorAll('#toc-wrapper .toc-link')];
if ( links.length === 0 ) return null;
const sections = links
.map(link => ({ link, heading: document.getElementById(link.getAttribute('href').slice(1)) }))
.filter(section => section.heading);
// At the bottom of the page the last sections can't scroll up to the
// offset, so the last one counts as current.
const page = document.scrollingElement;
if ( sections.length > 0 && window.innerHeight + window.scrollY >= page.scrollHeight - 2 ) {
return sections[sections.length - 1].link;
}
// Before the first heading, the page title link is current.
let current = links[0];
for ( const { link, heading } of sections ) {
if ( heading.getBoundingClientRect().top > ACTIVE_OFFSET ) break;
current = link;
}
return current;
}
// Scroll the TOC itself just enough to show the active link.
function keepInView (link) {
const toc = link.closest('.table-of-contents');
if ( ! toc ) return;
const tocBox = toc.getBoundingClientRect();
const linkBox = link.getBoundingClientRect();
if ( linkBox.top < tocBox.top ) {
toc.scrollTop -= tocBox.top - linkBox.top;
} else if ( linkBox.bottom > tocBox.bottom ) {
toc.scrollTop += linkBox.bottom - tocBox.bottom;
}
}
// The bar beside the active link is one element that moves to the link and
// takes its height, so it slides and resizes between entries of different
// heights instead of jumping.
function moveIndicator (link) {
const nav = link.closest('.toc-nav');
if ( ! nav ) return;
let indicator = nav.querySelector('.toc-indicator');
const isNew = ! indicator;
if ( isNew ) {
indicator = document.createElement('div');
indicator.className = 'toc-indicator';
indicator.style.transition = 'none';
nav.append(indicator);
}
// Measured from the boxes rather than offsetTop, which rounds to whole
// pixels and can leave the bar a pixel off the link.
const linkBox = link.getBoundingClientRect();
const transform = `translateY(${linkBox.top - nav.getBoundingClientRect().top}px)`;
const height = `${linkBox.height}px`;
if ( indicator.style.transform !== transform ) indicator.style.transform = transform;
if ( indicator.style.height !== height ) indicator.style.height = height;
if ( isNew ) {
// Place a new indicator without animating it in from the top.
indicator.getBoundingClientRect();
indicator.style.transition = '';
}
}
function updateActiveLink () {
pendingFrame = null;
const link = findActiveLink();
if ( link !== activeLink ) {
activeLink?.classList.remove('active');
activeLink = link;
if ( link ) {
link.classList.add('active');
keepInView(link);
}
}
// Also runs when only the layout changed, such as a resize that rewraps
// the active link onto more lines.
if ( link ) moveIndicator(link);
}
function scheduleUpdate () {
if ( pendingFrame === null ) {
pendingFrame = requestAnimationFrame(updateActiveLink);
}
}
window.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', scheduleUpdate);
$(document).ready(scheduleUpdate);
// The router replaces the TOC when it loads another page.
$(document).on('pathchange', function () {
activeLink = null;
scheduleUpdate();
});
+32 -2
View File
@@ -44,8 +44,8 @@ const examples = [
title: 'AI',
children: [
{
title: 'Chat with GPT-5.6 Luna',
description: 'Chat with GPT-5.6 Luna using Puter.js AI API. Run and experiment with this chatbot example directly in the playground.',
title: 'Chat with GPT-6 Luna',
description: 'Chat with GPT-6 Luna using Puter.js AI API. Run and experiment with this chatbot example directly in the playground.',
slug: 'ai-chatgpt',
source: '/playground/examples/ai-chatgpt.html',
},
@@ -67,6 +67,12 @@ const examples = [
slug: 'ai-chat-stream',
source: '/playground/examples/ai-chat-stream.html',
},
{
title: 'Stop a stream',
description: 'Stop a streaming AI chat response with an AbortSignal in Puter.js. Run and experiment with this example in the playground.',
slug: 'ai-chat-stop-stream',
source: '/playground/examples/ai-chat-stop-stream.html',
},
{
title: 'Function Calling',
description: 'Try AI function calling with Puter.js. Run and experiment with this advanced example directly in the playground.',
@@ -700,6 +706,18 @@ const examples = [
slug: 'events-get-schedule',
source: '/playground/examples/events-get-schedule.html',
},
{
title: 'Move a Reminder',
description: 'Move a notification stored to send later to a new time and change its title, keeping its id, with the Puter.js Events API. Run it in the playground.',
slug: 'events-update-reminder',
source: '/playground/examples/events-update-reminder.html',
},
{
title: 'Move a Schedule to Another Time',
description: 'Move a weekday schedule to another hour without creating a new one, using the Puter.js Events API. Run it in the playground.',
slug: 'events-update-cron',
source: '/playground/examples/events-update-cron.html',
},
{
title: 'Remove a Schedule',
description: 'End a schedule with the Puter.js Events API, then read the ended schedule. Run it in the playground.',
@@ -759,6 +777,18 @@ const examples = [
slug: 'notifications-send-later',
source: '/playground/examples/notifications-send-later.html',
},
{
title: 'Mark a Notification as Shown',
description: 'Mark a notification as shown so the next one in its group arrives as a new notification, with the Puter.js Notifications API. Run it in the playground.',
slug: 'notifications-mark-shown',
source: '/playground/examples/notifications-mark-shown.html',
},
{
title: 'Dismiss a Notification',
description: 'Dismiss a notification and hear about it on every open page of your app, with the Puter.js Notifications API. Run it in the playground.',
slug: 'notifications-dismiss',
source: '/playground/examples/notifications-dismiss.html',
},
],
},
{
+4 -4
View File
@@ -166,15 +166,15 @@ Puter.js is powered by [Puter](https://github.com/HeyPuter/puter), the open-sour
<div class="example-content" data-section="ai" style="display:block;">
#### Chat with GPT-5.6 Luna
#### Chat with GPT-6 Luna
```html;intro-chatgpt
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
// Chat with GPT-5.6 Luna
puter.ai.chat(`What is life?`, { model: "gpt-5.6-luna" }).then(puter.print);
// Chat with GPT-6 Luna
puter.ai.chat(`What is life?`, { model: "gpt-6-luna" }).then(puter.print);
</script>
</body>
</html>
@@ -190,7 +190,7 @@ Puter.js is powered by [Puter](https://github.com/HeyPuter/puter), the open-sour
<script>
puter.ai
.chat(`What do you see?`, `https://assets.puter.site/doge.jpeg`, {
model: "gpt-5.6-luna",
model: "gpt-6-luna",
})
.then(puter.print);
</script>
@@ -0,0 +1,28 @@
<html>
<body>
<button id="stop">Stop</button>
<div id="output" style="white-space: pre-wrap"></div>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const controller = new AbortController();
const stopButton = document.getElementById('stop');
const output = document.getElementById('output');
stopButton.onclick = () => controller.abort();
try {
const response = await puter.ai.chat('Write a long story', {
stream: true,
signal: controller.signal,
});
for await (const part of response) {
if (part.type === 'text') output.append(part.text);
}
} catch (error) {
if (error.name !== 'AbortError') throw error;
} finally {
stopButton.remove();
}
})();
</script>
</body>
</html>
@@ -5,9 +5,9 @@
// Loading ...
puter.print(`Loading...`);
// Chat with GPT-5.6 Luna
// Chat with GPT-6 Luna
puter.ai.chat(`What is life?`, {
model: 'gpt-5.6-luna',
model: 'gpt-6-luna',
}).then(puter.print);
</script>
</body>
@@ -6,10 +6,10 @@
// Loading ...
puter.print(`Loading...`);
// Image analysis with GPT-5.6 Luna
// Image analysis with GPT-6 Luna
puter.ai
.chat(`What do you see?`, `https://assets.puter.site/doge.jpeg`, {
model: "gpt-5.6-luna",
model: "gpt-6-luna",
})
.then(puter.print);
</script>
@@ -4,7 +4,7 @@
<script>
// Generate an image of a cat playing the piano using a specific model and quality set to low
puter.ai.txt2img("a cat playing the piano", {
model: "gpt-image-2",
model: "gpt-image-2.6-flare",
quality: "low"
}).then((image)=>{
document.body.appendChild(image);
@@ -5,7 +5,7 @@
puter.print(`Loading...`);
puter.ai
.chat("Summarize what the User-Pays Model is: https://docs.puter.com/user-pays-model/", {
model: "openai/gpt-5.6-luna",
model: "openai/gpt-6-luna",
tools: [{ type: "web_search" }],
})
.then(puter.print);
@@ -0,0 +1,36 @@
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
if (!await puter.perms.request('backgroundEvents')) {
puter.print('background events were not allowed<br>');
return;
}
// (1) Weekdays at 09:00 in this browser's time zone
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const schedule = await puter.events.onSchedule({
cron: '0 9 * * 1-5',
tz,
handler: async ({ user }) => {
await user.notifications.notify({ title: 'Check your tasks' });
},
});
puter.print(`next run: ${new Date(schedule.nextRunAt).toLocaleString()}<br>`);
// (2) Move it to 10:00, keeping the same id and handler
const updated = await puter.events.updateSchedule(schedule.scheduleId, {
cron: '0 10 * * 1-5',
tz,
});
puter.print(`next run: ${new Date(updated.nextRunAt).toLocaleString()}<br>`);
// (3) Cleanup: end the schedule, then remove the handler it created
await schedule.off();
await puter.events.handlers.remove(schedule.handlerName, { scope: 'user' });
puter.print('removed<br>');
})();
</script>
</body>
</html>
@@ -0,0 +1,27 @@
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
// (1) Store a reminder for an hour from now
const reminder = await puter.notifications.notify({
title: 'Review your notes',
sendAt: Date.now() + 60 * 60 * 1000,
});
if (typeof reminder !== 'object' || !reminder) return; // due now, so it was sent at once
puter.print(`goes out ${new Date(reminder.sendAt).toLocaleTimeString()}<br>`);
// (2) Move it an hour later and change what it says, keeping its id
const updated = await puter.events.updateSchedule(reminder.scheduleId, {
sendAt: Date.now() + 2 * 60 * 60 * 1000,
title: 'Review your meeting notes',
});
puter.print(`now goes out ${new Date(updated.nextRunAt).toLocaleTimeString()}<br>`);
// (3) Cleanup
await puter.notifications.cancel(reminder.scheduleId);
puter.print('cancelled<br>');
})();
</script>
</body>
</html>
@@ -5,9 +5,9 @@
// Loading ...
puter.print(`Loading...`);
// Chat with GPT-5.6 Luna
// Chat with GPT-6 Luna
puter.ai.chat(`What is life?`, {
model: 'gpt-5.6-luna',
model: 'gpt-6-luna',
}).then(puter.print);
</script>
</body>
@@ -6,10 +6,10 @@
// Loading ...
puter.print(`Loading...`);
// Image analysis with GPT-5.6 Luna
// Image analysis with GPT-6 Luna
puter.ai
.chat(`What do you see?`, `https://assets.puter.site/doge.jpeg`, {
model: "gpt-5.6-luna",
model: "gpt-6-luna",
})
.then(puter.print);
</script>
@@ -0,0 +1,18 @@
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
// (1) Every open page of the app hears about dismissals, on any device
puter.notifications.on('dismiss', ({ uid }) => {
puter.print(`dismissed ${uid}<br>`);
});
// (2) Send one, then dismiss it
const uid = await puter.notifications.notify({ title: 'New message from Ana' });
const dismissed = await puter.notifications.dismiss(uid);
puter.print(`dismiss() resolved ${dismissed}<br>`);
})();
</script>
</body>
</html>
@@ -0,0 +1,19 @@
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const first = await puter.notifications.notify({ title: '1 new message', group: 'inbox' });
// (1) Unshown, so the next one in the group replaces it and keeps its uid
const second = await puter.notifications.notify({ title: '2 new messages', group: 'inbox' });
puter.print(`replaced: ${second === first}<br>`);
// (2) Marked shown, so the next one in the group is a new notification
await puter.notifications.markShown(second);
const third = await puter.notifications.notify({ title: '1 new message', group: 'inbox' });
puter.print(`new notification: ${third !== second}<br>`);
})();
</script>
</body>
</html>
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more