RPC, Errors & Uploads
pp.rpc(name, data?, options?) calls a named function on your server and returns a promise. This page covers the client side in full: every option, every shape the promise can resolve to, the typed error it rejects with, request races, uploads and streaming. The server side of the same wire is in Implement In Your Backend.
Options
| Option | Behavior |
|---|---|
abortPrevious: boolean |
Cancel the previous in-flight call that also used abortPrevious. The slot is shared by the whole page, not per function name. Passing true as the third argument is shorthand for { abortPrevious: true }. |
url: string |
POST somewhere other than the current route (default: location.pathname with trailing slashes stripped). |
csrfUrl: string |
Where to GET when no CSRF cookie exists yet (default: the request URL). |
credentials: RequestCredentials |
Default: same-origin for the page's own origin, include for any other origin. |
onStream(chunk) |
Called once per SSE data: line when the response is text/event-stream. |
onStreamComplete() |
Called when the stream ends. |
onStreamError(error) |
Receives ANY failure of the call, not only stream failures. When set, the promise resolves undefined instead of rejecting. |
onUploadProgress({ loaded, total, percent }) |
Switches a file upload to XMLHttpRequest so progress can be reported. total and percent are null when the length is unknown. There is no 'percentage' key. |
onUploadComplete() |
Called after a successful upload response (progress path only). |
What the promise resolves to
| Server response | Result |
|---|---|
| 2xx, application/json | The parsed JSON. A void function must still return JSON (null), because the client always parses the body. |
| 2xx, text/event-stream | undefined, after the last chunk has been delivered to onStream. |
| X-PP-Redirect header | { redirected: true, to } after the client has navigated. See Server-driven redirects. |
| Cancelled by abortPrevious | { cancelled: true }. It resolves; it does not reject. |
| Non-2xx | Rejects with an RpcError (below), or resolves undefined when onStreamError is set. |
Errors: RpcError
A non-2xx response rejects with an RpcError. message is the text you
already had before this error type existed, so old code keeps working. The new fields
carry what the server sent:
status: the HTTP status (401, 403, 422, 500, …).errors: field messages from a validation failure, shaped{ field: ["message", …] }. Always an object, empty when the server sent none.requestId: the server's correlation id to quote in a support request, ornull.body: the parsed JSON body, ornullwhen it was not JSON.
| Status | error.message | Notes |
|---|---|---|
401 |
Authentication required | Fixed message; the body is still parsed into errors/requestId/body. |
403 |
Permission denied | Fixed message. |
415 with no JSON body |
Server rejected data format (415). | Usually a server that only accepts one content type. |
Anything else |
body.error, or 'Request failed: <status> <statusText>' | The upload (XHR) path also falls back to body.message. |
A form that shows field errors, a general failure and the reference id:
<form pp-component="signup_form" onsubmit="submit(event)">
<input name="email" />
<p class="error" hidden="{!errors.email}">{errors.email?.[0]}</p>
<button disabled="{pending}">Create account</button>
<p hidden="{!failure}">{failure} <small>(ref: {requestId})</small></p>
<script>
const [errors, setErrors] = pp.state({});
const [failure, setFailure] = pp.state("");
const [requestId, setRequestId] = pp.state("");
const [pending, startTransition] = pp.transition();
const submit = (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget).entries());
startTransition(async () => {
try {
await pp.rpc("signUp", data);
setErrors({});
setFailure("");
} catch (error) {
// error.name === "RpcError"
setErrors(error.errors); // { email: ["Already registered"] }
setFailure(error.message); // body.error, or a fixed 401/403 text
setRequestId(error.requestId ?? "");
}
});
};
</script>
</form>
For that to work, send this shape from the server (any language). Only error was ever required; errors and requestId are optional:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"error": "Please fix the highlighted fields.",
"errors": { "email": ["Already registered"], "password": ["Too short"] },
"requestId": "req_7f3a9c"
}
Request races
Typing into a search box fires overlapping calls, and they can finish in any order.
abortPrevious cancels the older call, which resolves with { cancelled: true }:
<div pp-component="user_search">
<input value="{query}" oninput="search(target.value)" placeholder="Search users" />
<ul>
<template pp-for="user in results">
<li key="{user.id}">{user.name}</li>
</template>
</ul>
<script>
const [query, setQuery] = pp.state("");
const [results, setResults] = pp.state([]);
const search = async (text) => {
setQuery(text);
const rows = await pp.rpc("searchUsers", { text }, { abortPrevious: true });
if (rows?.cancelled) return; // a newer keystroke superseded this call
setResults(rows);
};
</script>
</div>
The cancel slot is shared by the whole page. Any call made with
abortPrevious cancels the last call made with it, whichever component or
function made that call. When two independent lists need their own race protection,
keep a generation counter in a pp.ref instead:
// Two independent lists on one page? abortPrevious is page-wide, so the
// second list would cancel the first. Use a generation counter instead.
const generation = pp.ref(0);
const load = async (filters) => {
const mine = ++generation.current;
const rows = await pp.rpc("listOrders", filters);
if (mine !== generation.current) return; // stale response, drop it
setOrders(rows);
};
File uploads
Put a File or a non-empty FileList anywhere in data and the body becomes
multipart/form-data. You don't need an upload endpoint or a separate helper:
<form pp-component="avatar_upload" onsubmit="upload(event)">
<input type="file" name="avatar" accept="image/*" />
<input name="caption" />
<progress max="100" value="{progress}" hidden="{progress === null}"></progress>
<button>Upload</button>
<script>
const [progress, setProgress] = pp.state(null);
const upload = async (event) => {
event.preventDefault();
const form = event.currentTarget;
await pp.rpc(
"uploadAvatar",
{ avatar: form.avatar.files[0], caption: form.caption.value },
{
onUploadProgress: ({ percent }) => setProgress(percent ?? 0),
onUploadComplete: () => setProgress(null),
},
);
form.reset();
};
</script>
</form>
- Field order: every non-file value is written before the first file, so a server that streams the upload can read the other arguments before the file arrives.
- Objects and arrays are sent as JSON strings;
nullandundefinedvalues are left out; aFileListis appended under one name, so it arrives as a list. - Without
onUploadProgressthe upload usesfetch; with it,XMLHttpRequest. Both send the same headers and follow the same redirect and error rules. - Serve stored uploads as attachments (not inline HTML) unless they are verified image types. That is the server's job and it applies to every backend.
Streaming
When the server answers with Content-Type: text/event-stream, the same call
becomes a stream. Use it for LLM tokens and progress feeds instead of opening a WebSocket:
<div pp-component="ai_answer">
<button onclick="ask()">Explain PulsePoint</button>
<p>{text}</p>
<p hidden="{!done}">Done.</p>
<script>
const [text, setText] = pp.state("");
const [done, setDone] = pp.state(false);
const ask = () => {
setText("");
setDone(false);
pp.rpc("explain", { topic: "PulsePoint" }, {
onStream: (chunk) => setText((t) => t + chunk),
onStreamComplete: () => setDone(true),
onStreamError: (error) => setText("Failed: " + error.message),
});
};
</script>
</div>
How each line is decoded. Every data: line is one chunk; multi-line events are not joined, so send one line per chunk:
data: "Pulse" -> onStream("Pulse") (JSON string)
data: {"step": 2} -> onStream({ step: 2 }) (JSON object)
data: 42 -> onStream(42) (number)
data: plain text -> onStream("plain text") (not JSON: passed as text)
event: progress -> ignored (only "data: " lines are read)
A stream stays in flight until its last chunk, so abortPrevious can still
cancel it. A stream that arrives with no onStream handler logs a warning
and its chunks are dropped.
How the CSRF token is found
- When the page URL has an explicit port, the cookie
pp_csrf_<port>is read first, so two dev servers on one host don't overwrite each other's token. - Otherwise, or if that cookie is missing,
pp_csrfis read. - If neither exists, the client makes one
GETtocsrfUrl(or the request URL), expecting the server to set the cookie, then reads it again. This is skipped whencredentialsis"omit". - The value is sent as
X-CSRF-Token. The cookie must not beHttpOnly, because the client has to read it.