@fastrp/phone-app-sdk
v0.3.0
Published
SDK, wire protocol and declaration contract for Fast:V Roleplay in-game phone apps.
Readme
@fastrp/phone-app-sdk
The contract between a Fast Roleplay in-game phone app and the phone that runs it: the wire protocol, the action and permission catalogue, the declaration rules, the per-app CSP, the guest SDK (vanilla and React) and the offline preview harness.
Published to npm so every consumer shares one definition:
| Consumer | Uses |
| --- | --- |
| App authors | @fastrp/phone-app-sdk/client or /react; dist/child.umd.js vendored into the package |
| public-gateway | Serves dist/child.umd.js, dist/child.d.ts, dist/preview.html |
| sites-platform, phone-service | CSP builder, declaration validation, permission catalogue |
| The game's NUI host | Protocol types, PHONE_APP_ACTION_SPECS, rate limits, event names |
| developers.fast-rp.com | Renders the reference tables from this package's constants |
Full developer documentation (Turkish): https://developers.fast-rp.com/docs/phone-apps.
Install
bun add @fastrp/phone-app-sdk # or npm / pnpmThe package's major.minor tracks the protocol line it speaks (0.3.x ⇢ protocol 0.3). The
version reported in the handshake is the package version, which is what the host checks.
The vendoring rule
Apps are served with script-src 'self'. A <script src="https://…/child.umd.js"> is blocked by
the browser and the app silently never connects. So the UMD ships inside the app package:
- with the starter template,
bun run packcopiesnode_modules/@fastrp/phone-app-sdk/dist/child.umd.jstopublic/fastapp-sdk.jsbefore building; - with a bundler and
import { createPhoneSDK } from '@fastrp/phone-app-sdk/client', the SDK is simply part of your bundle and nothing needs copying.
Either way the bytes are the app's own. The host learns the SDK version during the handshake, so a broken build can still be refused centrally.
Quick start
Vanilla
<script src="./fastapp-sdk.js"></script>
<script>
const sdk = window.PhoneSDK.createPhoneSDK();
sdk.whenReady({ timeoutMs: 3000 }).then(
(context) => {
document.body.style.background = context.theme.background;
console.log(context.installId, context.permissions);
},
(error) => {
// TIMEOUT: not inside the phone. PROTOCOL_MISMATCH: update the SDK.
console.warn(error.code);
},
);
sdk.on('theme.changed', (theme) => {
document.body.style.background = theme.background;
});
</script>ESM
import { createPhoneSDK, isPhoneError } from '@fastrp/phone-app-sdk/client';
const sdk = createPhoneSDK();
const context = await sdk.whenReady();
await sdk.storage.set('cart', { items: 2 });
const cart = await sdk.storage.get<{ items: number }>('cart');
try {
await sdk.phone.notify({ title: 'Order ready' });
} catch (error) {
if (isPhoneError(error) && error.code === 'PERMISSION_DENIED') {
// Ask for it — the phone shows the grant sheet, the promise resolves with the new list.
await sdk.permissions.request(['phone.notify']);
}
}React
import { PhoneAppProvider, usePhoneApp, usePhoneContext, usePhonePermission, usePhoneEvent }
from '@fastrp/phone-app-sdk/react';
function App() {
const { sdk, status } = usePhoneApp(); // 'connecting' | 'ready' | 'mismatch' | 'outside'
const context = usePhoneContext(); // re-renders on theme/permission/viewport events
const notify = usePhonePermission('phone.notify');
usePhoneEvent('notification', (push) => console.log(push.title));
if (status !== 'ready' || !context) return <p>{status}</p>;
return (
<button onClick={() => notify.granted ? sdk.phone.notify({ title: 'Hi' }) : notify.request()}>
{notify.granted ? 'Notify' : 'Allow notifications'}
</button>
);
}
createRoot(root).render(
<PhoneAppProvider>
<App />
</PhoneAppProvider>,
);react >= 18 is an optional peer dependency; the core package has no dependencies.
What 0.3 adds
Protocol 0.3 is additive: a 0.2 app keeps working against a 0.3 phone, and the 0.3 SDK keeps
every 0.2 method. New in the context (sdk.getContext() / usePhoneContext()):
| Field | Type | Live via |
| --- | --- | --- |
| locale | 'en' \| 'tr' — the player's game language | locale.changed |
| actions | string[] — every action this host answers | — |
| signal | { strength, bars, status } — cell signal | signal.changed |
| launch? | { source: 'push' \| 'app', from?, data? } — why the app was opened; absent from the home screen | — |
sdk.supports('map.open') reads context.actions, so an app can hide a feature instead of
catching UNKNOWN_ACTION. Against a 0.2 host (no actions field) it reports the 0.2 catalogue.
New host → guest events: locale.changed, signal.changed and storage.changed
({ key, source: 'backend' } — your backend wrote a key through the public gateway).
New actions and their SDK methods:
| Action | SDK | Permission | Consent |
| --- | --- | --- | --- |
| storage.clear | storage.clear() | — | — |
| phone.openApp | phone.openApp({ appId, data? }) | — | once per target app per session; the target reads context.launch |
| contacts.pick | contacts.pick() → { name, phone } \| null | — | the picker itself |
| map.setWaypoint | map.setWaypoint(x, y, label?) | map.waypoint (new) | every call |
| map.clearWaypoint | map.clearWaypoint() | map.waypoint | every call |
| map.open | map.open(x, y, name?) | — | — |
| environment.get | environment.get() → { hour, minute, weather, serverTime } | — | — |
| browser.open | browser.open({ url }) — https://*.gta5fast.com only | — | — |
PHONE_APP_LIMITS.launchDataBytes (1 KB) caps phone.openApp data; waypointLabelChars (40)
caps the waypoint label. PHONE_APP_DATA_ORIGIN and PHONE_APP_UPLOAD_ORIGIN (the public
gateway and the S3 upload host) are admitted to every app's connect-src without being declared —
they are what the data client below talks to.
App Data — @fastrp/phone-app-sdk/data
A backend you do not run: JSON documents in named collections, optional app accounts, and file
uploads, behind the install token the phone already gives you. Collections, their JSON schema,
rules (read/update/delete ∈ none|owner|app, create ∈ none|app) and indexes are configured in
the developer portal; the SDK only reads and writes.
import { createDataClient, isDataError } from '@fastrp/phone-app-sdk/data';
import { sdk } from './phone';
export const data = createDataClient({ sdk });
type Order = { item: string; qty: number };
const orders = data.collection<Order>('orders');
const created = await orders.create({ item: 'latte', qty: 1 }); // Doc<Order>
await orders.update(created.id, { qty: 2 }); // JSON merge patch
const page = await orders.list({
where: [['qty', 'gte', 2]],
orderBy: [['createdAt', 'desc']],
limit: 20,
}); // { items, nextCursor }
const stop = orders.subscribe({ where: [['item', 'eq', 'latte']] }, (change) => {
// { docId, op: 'create' | 'update' | 'delete', data | null, ownerSubject, version, at }
});
try {
await orders.remove('someone-elses');
} catch (error) {
if (isDataError(error) && error.code === 'FORBIDDEN') { /* the collection's rules said no */ }
}DataError.code is one of UNAUTHORIZED | FORBIDDEN | NOT_FOUND | VALIDATION | QUOTA |
RATE_LIMITED | CONFLICT | NETWORK; status and details carry what the gateway said.
Accounts and files:
await data.auth.signUp('ada', 'correct horse'); // or signIn; documents are now owned by acct:<id>
const { code } = await data.auth.createLinkCode(); // typed on another character: data.auth.link(code)
data.auth.onChange((state) => state.status); // 'unknown' | 'anonymous' | 'authenticated'
await data.auth.signOut();
const file = await data.files.upload(blob, { name: 'receipt.png' }); // { id, url, name, mime, size }
await data.files.remove(file.id);The session lives in sdk.storage under __fastapp_session (reserved — do not write it yourself)
and rides along as X-App-Session; the install token is refreshed 60 s before it expires and never
asked of the host more than once per 30 s.
React
import { DataProvider, useCollection, useDocument, useAuth } from '@fastrp/phone-app-sdk/data/react';
<PhoneAppProvider sdk={sdk}>
<DataProvider client={data}>
<App />
</DataProvider>
</PhoneAppProvider>
function Orders() {
const { items, loading, error, refetch } = useCollection<Order>(
'orders',
{ orderBy: [['createdAt', 'desc']], limit: 50 },
{ realtime: true }, // merges SSE changes into `items` by id
);
const { doc } = useDocument<Order>('orders', items[0]?.id, { realtime: true });
const auth = useAuth(); // { status, loading, account, subject, accountsEnabled, signIn, signUp, signOut, link, createLinkCode, refresh }
…
}Local development — the memory transport
The preview harness cannot fake /v1/data: your app runs in a frame on a different origin, so
its fetch is out of the harness's reach. Fake it on your side instead:
import { createDataClient, createMemoryDataTransport } from '@fastrp/phone-app-sdk/data';
export const data = createDataClient(
{ sdk },
{ transport: import.meta.env.DEV ? createMemoryDataTransport() : undefined },
);createMemoryDataTransport({ seed? }) answers the whole REST surface in memory — collections,
query/order/paging, SSE changes, accounts, link codes, uploads — with rules app, no schema
validation, no quota, and no token check (the harness's alg: none token is accepted). Data lives
until the page reloads. Drop the transport to hit the real gateway.
Server side
const data = createDataClient({ credentials: { clientId, clientSecret } });Exchanges client_credentials (phone-app:server) at <baseUrl>/oauth/token and acts as the app
itself, bypassing collection rules. Never ship a client secret inside a phone app.
Reference
The action, permission, event, error-code and limit tables are rendered on the developer site from this package's constants, so they cannot drift from what the host enforces. They are not repeated here.
- Actions, rate limits, events and
PHONE_APP_LIMITS: https://developers.fast-rp.com/docs/phone-apps/sdk - Permissions and consent sheets: https://developers.fast-rp.com/docs/phone-apps/sdk/permissions
- Error codes: https://developers.fast-rp.com/docs/phone-apps/errors
In code, the same tables are PHONE_APP_ACTION_SPECS, PHONE_APP_GLOBAL_RATE_LIMIT,
PHONE_APP_LIMITS, PHONE_APP_PERMISSIONS, PHONE_APP_PERMISSION_CONSENT and
PHONE_APP_EVENTS; every rejection is a PhoneError whose code is a PhoneAppErrorCode.
Protocol compatibility
PHONE_APP_PROTOCOL_VERSION is 0.3. The host answers ready to every line in
PHONE_APP_HOST_ACCEPTS (['0.1', '0.2', '0.3']), compared major.minor with
isCompatibleProtocol. A 0.1 guest ignores the event message type it does not know, and reads
installId under the old characterId key; a 0.2 guest ignores the context fields and events 0.3
added — both keep working unchanged. A 0.4 guest gets PROTOCOL_MISMATCH and every call rejects
rather than half-working. The host must ship 0.3 before any 0.3 app is published.
The gateway serves the same current build for /api/sdk/0.1/…, /api/sdk/0.2/… and
/api/sdk/0.3/… (and any patch on each).
Two things that must not drift
script-src stays 'self'. No app ever admits an external script origin. Adding one here
would add it to every app on the platform at once.
connect-src never contains https://fast-webview. That host is the NUI callback endpoint;
reaching it means reaching the entire client→server event bus. declaration.ts refuses the
hostname and csp.ts filters it again on the way out — a rule this load-bearing is checked on both
sides of the boundary, and declaration.test.ts pins both.
Layout
src/protocol.ts wire format: envelope, handshake, events, error codes
src/actions.ts action catalogue, typed params/results, rate limits, PHONE_APP_LIMITS
src/permissions.ts permission catalogue and consent modes
src/declaration.ts the rules a declared hostname must pass before reaching connect-src
src/platform-origins.ts the data API and upload origins every app may reach undeclared
src/csp.ts per-app Content-Security-Policy
src/client/ the guest SDK (createPhoneSDK, PhoneError)
src/react/ PhoneAppProvider and hooks
src/data/ the App Data client (createDataClient, DataError, createMemoryDataTransport)
src/data/react/ DataProvider, useCollection, useDocument, useAuth
scripts/build.ts bundles dist/ (see below) and emits declarations
scripts/prepare-publish.ts swaps the workspace `exports` for the dist map before `npm publish`
preview.html standalone harness that mocks the phone in a browserBuild and publish
bun run build # dist/index.js, client.js, react.js, data.js, data-react.js, child.umd.js, child.d.ts, preview.html, *.d.ts
bun run check-types
bun testIn the workspace, exports resolves to src/ for Bun (bun condition) and TypeScript (types),
so no build is needed to type-check or run the other packages. publishConfig.exports holds the
dist/ map; .github/workflows/pkg-phone-app-sdk.yml swaps it in and runs npm publish when the
version in package.json is not yet on the registry. Bumping the version is the release.
public-gateway reads dist/ at boot, so its Dockerfile runs this build; locally run it once
before starting the gateway.
