# Tinylib developer docs
> Every page of https://tinylib.app/docs, in order, with a working app after Start.
## Make an app
Every page of these docs is also one file at [/llms.txt](/llms.txt), to give an AI assistant.
```sh
npx tinylib-sdk init my-app
cd my-app
npm install
```
`init` makes the folder from a template: `journal`, an app for one person with a daily reminder, or `tictactoe`, a game for two in a room, with rules. In a terminal it asks for the template, the app's name, its slug and its description; `--template`, `--name`, `--slug` and `--description` give them, and without a terminal each takes its default. It writes only into an empty folder or a new one. `package.json` has `tinylib-sdk` and two scripts, `npm run dev` and `npm run publish`, which run `tinylib dev` and `tinylib publish`.
### The folder
```text
my-app/
tinylib.json
index.html
app.js
icon.svg
rules.js only for an app with rooms
package.json
```
```json
{ "slug": "my-app", "name": "My app", "description": "What it does, in a line.", "icon": "icon.svg" }
```
```html
```
```js
const count = tinylib.data.get('count') ?? 0
document.getElementById('app').textContent = `Hello, ${tinylib.persona.name}. Opened ${count + 1} times.`
tinylib.data.set('count', count + 1)
```
Tinylib puts its script first in every HTML page of the folder, so `tinylib` is there from the first line of `app.js`, with the person's data already loaded. Every file in the folder ships in the publish, at the same path, except dotfiles, `node_modules`, and the folder's `package.json` and `package-lock.json`. The fields of `tinylib.json` are on [its reference page](https://tinylib.app/docs/reference/tinylib-json).
### Run it
```sh
npm run dev
```
`dev` serves Tinylib at `localhost:4400` with the app in it, and opens the browser. It runs `rules.js` on your computer, with simulated people to share rooms with. Any change in the folder reloads every page. In a folder without `init`'s `package.json`, `npx tinylib-sdk dev` does the same.
### Publish
```sh
npm run publish
```
`publish` builds the folder, checks it against the rooms open on the current publish, and makes it the one everyone gets. The first time, it opens the browser to log this computer in. It prints the app's link, which opens on a phone.
A publish reloads every open page of the app. The first publish claims the slug, first come first served.
### Preview
```sh
npx tinylib-sdk preview --tester ben@example.com
```
`preview` publishes the folder as the app's preview, open only to you and the testers it emails. A preview has its own data and rooms. `--end` ends it.
### Types
```js
///
```
With `tinylib-sdk` installed in the folder, a script that starts with this line has `tinylib` typed; `init`'s scripts start with it. `rules.js` imports from `tinylib-sdk/rules`, which types its kinds. Scripts and rules may be TypeScript: `dev` and `publish` strip the types and don't check them. [`Register`](https://tinylib.app/docs/reference/tinylib#register) types an app's data and the messages each kind of room sends.
## A working app
Tic-tac-toe for two, with rules: the example `npx tinylib-sdk init --template tictactoe` copies. Each file sits in the app's folder under its name.
### tinylib.json
```json
{
"slug": "tictactoe",
"name": "Tic-tac-toe",
"description": "Noughts and crosses with a friend.",
"icon": "icon.svg",
"uses": ["notifications"]
}
```
### index.html
```html
Tic-tac-toe
```
### style.css
```css
/* Base: the same in every Tinylib example. Copy it whole; this app's own rules follow it. */
:root {
color-scheme: light dark;
--bg: #f5f4f0;
--card: #ffffff;
--fg: #1d1d1f;
--muted: #6b6b72;
--line: #e0ddd5;
--accent: #2f5fa7;
--accent-fg: #ffffff;
--danger: #c0392b;
--danger-fg: #ffffff;
--radius: 12px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #131315;
--card: #1e1e21;
--fg: #ececef;
--muted: #9d9da6;
--line: #34343a;
--accent: #8cb4f2;
--accent-fg: #0b1a2e;
--danger: #ff8a7a;
--danger-fg: #2a0b06;
}
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
main { max-width: 560px; margin: 0 auto; padding: 16px 16px 48px; }
h1 { font-size: 1.5rem; line-height: 1.2; margin: 8px 0 12px; overflow-wrap: anywhere; }
h2 { font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin: 24px 0 8px; }
h3 { font-size: 1rem; margin: 16px 0 6px; }
p { margin: 8px 0; }
.muted { color: var(--muted); }
.error { color: var(--danger); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Buttons: plain for most, primary for the screen's main action, danger to confirm what can't be undone. */
button {
font: inherit;
font-weight: 600;
color: var(--fg);
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 10px 16px;
cursor: pointer;
}
button:disabled { opacity: .5; cursor: default; }
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
button.danger { background: var(--danger); border-color: var(--danger); color: var(--danger-fg); }
button.quiet { background: none; border-color: transparent; color: var(--accent); padding-inline: 4px; }
button.small { font-size: .85rem; font-weight: 500; padding: 4px 10px; border-radius: 8px; }
input:not([type=checkbox], [type=radio]), select, textarea {
font: inherit;
color: var(--fg);
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 10px 12px;
min-width: 0;
}
input[type=checkbox], input[type=radio] { accent-color: var(--accent); width: 20px; height: 20px; margin: 0; flex: none; }
label.check { display: flex; align-items: center; gap: 10px; cursor: pointer; }
/* Layout: a header bar, rows that wrap, and cards. */
.bar { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 44px; }
.row, .actions, .tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.row > input { flex: 1 1 8em; }
.actions { margin-top: 16px; }
.tools { gap: 6px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 16px; margin: 12px 0; }
.rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.rows > li { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 14px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
/* An in-page confirm, shown in place of the button that asked. */
.confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 16px; padding: 12px 14px; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); }
.confirm p { flex: 1 1 100%; margin: 0 0 4px; font-weight: 600; }
#toast { position: fixed; left: 16px; right: 16px; bottom: 16px; max-width: 528px; margin: 0 auto; padding: 12px 16px; border-radius: var(--radius); background: var(--fg); color: var(--bg); }
/* Tic-tac-toe */
:root { --accent: #1f3a5f; --x: #d9822b; --o: #2f80c1; }
@media (prefers-color-scheme: dark) { :root { --accent: #7fb2e5; --accent-fg: #0d1b2a; --x: #ffb347; --o: #7fd1ff; } }
.bar h1 { margin: 0; }
.muted:empty { display: none; }
.person { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.person span { font-weight: 600; }
.rows .main { flex: 1 1 160px; min-width: 0; text-align: left; background: none; border: 0; padding: 0; }
.rows .who { display: block; font-weight: 600; }
.rows .what { color: var(--muted); font-size: .9rem; font-weight: 400; }
.rows li.waiting .what { color: var(--fg); font-weight: 700; }
.status { font-size: 1.25rem; font-weight: 700; text-align: center; margin-top: 16px; }
#you { text-align: center; }
.message { text-align: center; color: var(--muted); margin-top: 48px; }
.board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
width: min(100%, 360px);
aspect-ratio: 1;
margin: 16px auto;
}
.board button {
font-size: clamp(2.5rem, 14vw, 4rem);
font-weight: 800;
line-height: 1;
padding: 0;
}
.board button:disabled { cursor: default; opacity: 1; }
.board .x { color: var(--x); }
.board .o { color: var(--o); }
.actions { justify-content: center; }
```
### app.js
```js
const $ = (id) => document.getElementById(id)
const show = (error) => toast(error.message)
let room = null // the open game; route() closes it
let view = null // the last view message the rules sent
let routing = 0 // counts routes, so a slow one that's been overtaken stops
let cleanup = [] // listeners to stop when the address changes
let opponent = null // kept after they leave, so Block and Report stay
async function route() {
const mine = ++routing
cleanup.forEach((stop) => stop())
cleanup = []
room?.close() // a room stays open across moves until the page closes it
room = null
view = null
opponent = null
const [prefix, id] = tinylib.places.address.split('?')[0].split('/')
if (prefix === 'game' && id) await openGame(id, mine)
else await showStart(mine)
}
// step 1
async function showStart(mine) {
drawStart()
$('new').onclick = newGame
let fresh = false // set once onChange has drawn, so an older list() answer doesn't draw over it
const drawRooms = (rooms) =>
drawGames(
rooms
.filter((r) => !r.ended && !r.left)
.map((r) => ({ ...r, against: r.members.find((m) => m.id !== tinylib.persona.id) })), // rows show r.status, bold when r.waiting
{ onBlock: (p) => tinylib.block(p.id).catch(show), onReport: (p) => tinylib.report(p.id).catch(show) },
)
cleanup.push(
tinylib.rooms.onChange((rooms) => {
fresh = true
drawRooms(rooms)
}),
)
cleanup.push(
tinylib.invitations.handle(
() => true, // the games list shows every invitation
(invitations) =>
drawInvitations(invitations, {
onAccept: (inv) => inv.accept().then(({ id }) => tinylib.places.go(`game/${id}`)).catch(show),
onDismiss: (inv) => inv.dismiss().catch(show),
onBlock: (inv) => tinylib.block(inv.from.id).catch(show),
onReport: (inv) => tinylib.report(inv.from.id).catch(show),
}),
),
)
cleanup.push(tinylib.connection.onChange(() => mine === routing && route()))
try {
const rooms = await tinylib.rooms.list() // offline: past rooms only
if (mine !== routing) return
if (!fresh) drawRooms(rooms)
$('games-status').textContent = tinylib.connection.online ? '' : 'No connection. Your games come back when you do.'
} catch (error) {
if (mine === routing) $('games-status').textContent = error.message
}
}
// step 2
async function newGame() {
try {
const created = await tinylib.rooms.create('game')
tinylib.places.go(`game/${created.id}`) // the app's own route
} catch (error) {
show(error)
}
}
// step 3
async function openGame(id, mine) {
try {
const opened = await tinylib.rooms.open(id)
if (mine !== routing) return opened.close()
room = opened
cleanup.push(
room.onMessage((message) => {
if (!message.view) return
view = message.view
drawGame()
}),
)
cleanup.push(room.onChange(drawGame))
cleanup.push(tinylib.connection.onChange(drawGame))
drawGame()
} catch (error) {
if (mine !== routing) return
showMessage(error.message)
if (error.code === 'offline') cleanup.push(tinylib.connection.onChange(() => tinylib.connection.online && route()))
}
}
function drawGame() {
if (room.left) return tinylib.places.go('')
if (!tinylib.connection.online) return showMessage('No connection. The game comes back when you do.')
if (!view) return showMessage('Loading…')
const { board, you, nextMark, winner, outcome } = view
opponent = view.opponent ?? opponent
const playing = !room.ended && !winner && !!view.opponent
drawBoard(board, playing && nextMark === you ? (square) => send('move', square) : null) // step 7
$('status').textContent = outcome ?? (winner === 'draw' ? 'Draw' : winner ? (winner === you ? 'You won' : 'You lost') : !view.opponent ? 'Waiting for someone to join' : nextMark === you ? 'Your turn' : 'Their turn')
// step 4
$('invite').hidden = !!view.opponent || room.ended
$('invite').onclick = () => tinylib.rooms.invite(room.id).catch(show)
// step 8
$('again').hidden = !winner || room.ended
$('again').onclick = () => send('again')
// step 9
$('opponent').hidden = !opponent
if (opponent) {
$('opponent-name').textContent = opponent.name
$('block').onclick = () => tinylib.block(opponent.id).catch(show)
$('report').onclick = () => tinylib.report(opponent.id).catch(show)
}
// step 10
$('leave').hidden = room.ended
$('leave').onclick = async () => {
try {
await tinylib.rooms.leave(room.id)
tinylib.places.go('')
} catch (error) {
show(error)
}
}
}
const send = (action, data) => room?.send(action, data).catch(show)
tinylib.places.onChange(route)
route()
// step 5: no code. Tinylib signs the friend in and lands them on `game/` and the id, the kind's address.
// The screen code. Function declarations only, so route() can call them before this part of the
// module has run.
function drawStart() {
$('game').hidden = true
$('start').hidden = false
$('games').replaceChildren()
$('no-games').hidden = true
$('games-status').textContent = ''
$('invitations-box').hidden = true
}
function drawGames(rows, { onBlock, onReport }) {
$('games').replaceChildren(
...rows.map((row) => {
const open = element('button', { className: 'main', onclick: () => tinylib.places.go(`game/${row.id}`) }, [
element('span', { className: 'who', textContent: row.against ? row.against.name : 'No one yet' }),
element('span', { className: 'what', textContent: row.status ?? '' }),
])
const tools = row.against ? personTools(() => onBlock(row.against), () => onReport(row.against)) : []
return element('li', { className: row.waiting ? 'waiting' : '' }, [open, ...tools])
}),
)
$('no-games').hidden = rows.length > 0
}
function drawInvitations(invitations, { onAccept, onDismiss, onBlock, onReport }) {
$('invitations-box').hidden = invitations.length === 0
$('invitations').replaceChildren(
...invitations.map((inv) =>
element('li', {}, [
element('div', { className: 'main' }, [
element('span', { className: 'who', textContent: inv.from.name }),
element('span', { className: 'what', textContent: 'Invited you to a game' }),
]),
element('div', { className: 'tools' }, [
element('button', { className: 'primary', textContent: 'Accept', onclick: () => onAccept(inv) }),
element('button', { textContent: 'Dismiss', onclick: () => onDismiss(inv) }),
...personTools(() => onBlock(inv), () => onReport(inv)),
]),
]),
),
)
}
function showMessage(text) {
showGameScreen()
$('play').hidden = true
$('message').hidden = false
$('message').textContent = text
$('opponent').hidden = !opponent
}
function drawBoard(board, onPick) {
showGameScreen()
$('message').hidden = true
$('play').hidden = false
$('you').textContent = view ? `You play ${view.you.toUpperCase()}` : ''
$('board').replaceChildren(
...board.map((mark, square) =>
element('button', {
className: mark,
textContent: mark.toUpperCase(),
disabled: !onPick || !!mark,
ariaLabel: mark ? `Square ${square + 1}, ${mark.toUpperCase()}` : `Square ${square + 1}, empty`,
onclick: () => onPick(square),
}),
),
)
}
function showGameScreen() {
$('start').hidden = true
$('game').hidden = false
$('back').onclick = () => tinylib.places.go('')
}
function personTools(onBlock, onReport) {
return [
element('button', { className: 'small', textContent: 'Block', onclick: onBlock }),
element('button', { className: 'small', textContent: 'Report', onclick: onReport }),
]
}
function toast(text) {
const box = $('toast')
box.textContent = text
box.hidden = false
clearTimeout(box.timer)
box.timer = setTimeout(() => (box.hidden = true), 4000)
}
function element(tag, props = {}, children = []) {
const node = Object.assign(document.createElement(tag), props)
node.append(...children)
return node
}
```
### rules.js
```js
import { withViews } from 'tinylib-sdk/rules'
const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]]
const markOf = (state, id) => (id === state.x ? 'x' : 'o')
function winnerOf(board) {
for (const [a, b, c] of LINES) if (board[a] && board[a] === board[b] && board[a] === board[c]) return board[a]
return board.every((square) => square) ? 'draw' : null
}
export const game = withViews({
address: 'game/:id', // where Tinylib sends people coming in from outside: a notification, an invitation, the link
// step 2, step 6
create(options, ctx) {
return { board: Array(9).fill(''), x: ctx.by.id, o: null, nextMark: 'x', winner: null, outcome: null }
},
on(state, input, ctx) {
switch (input.type) {
case 'join': // step 6
if (state.o) ctx.refuse('This game already has two players.')
state.o = input.from.id
ctx.notify(state.x, `${input.from.name} joined`, 'Your move.')
break
case 'action':
if (input.name === 'move') move(state, input, ctx) // step 7
else if (input.name === 'again') again(state, ctx) // step 8
else ctx.refuse('There is no such move.')
break
case 'leave': // step 10; the leaver is no longer in ctx.members, so the record keeps their name
state.outcome = `${input.from.name} left.`
ctx.end()
break
}
},
// step 3, step 9: names come from ctx.members, which is always current
view(state, member, ctx) {
return {
board: state.board,
you: markOf(state, member.id),
nextMark: state.nextMark,
winner: state.winner,
outcome: state.outcome,
opponent: ctx.members.find((m) => m.id !== member.id) ?? null,
}
},
// step 6
status(state, member) {
const me = markOf(state, member.id)
if (state.outcome) return { text: state.outcome, waiting: false }
if (!state.o) return { text: 'Waiting for someone to join', waiting: false }
if (state.winner === 'draw') return { text: 'Draw', waiting: false }
if (state.winner) return { text: state.winner === me ? 'You won' : 'You lost', waiting: false }
return { text: state.nextMark === me ? 'Your turn' : 'Their turn', waiting: state.nextMark === me }
},
})
function move(state, { from, data: square }, ctx) {
const mark = markOf(state, from.id)
if (!state.o) ctx.refuse('Wait for someone to join.')
if (state.winner) ctx.refuse('This round is over.')
if (mark !== state.nextMark) ctx.refuse("It's not your turn.")
if (!Number.isInteger(square) || square < 0 || square > 8 || state.board[square]) ctx.refuse('Pick an empty square.')
state.board[square] = mark
state.winner = winnerOf(state.board)
state.nextMark = mark === 'x' ? 'o' : 'x'
ctx.notify(mark === 'x' ? state.o : state.x, state.winner ? 'Game over' : 'Your turn')
}
function again(state, ctx) {
if (!state.winner) ctx.refuse('Finish this round first.')
;[state.x, state.o] = [state.o, state.x]
state.board = Array(9).fill('')
state.nextMark = 'x'
state.winner = null
}
// step 11: no code. A game nobody plays closes for inactivity.
```
## Data
```js
const text = tinylib.data.get(`entry/${day}`) ?? ''
tinylib.data.set(`entry/${day}`, 'Rain all day')
tinylib.data.remove(`entry/${day}`)
const days = tinylib.data.keys('entry/').reverse() // newest first
```
`tinylib.data` holds the persona's values in this app. They're on the device before the page's first line runs, and every call is synchronous. `set` saves on the device at once and syncs to the person's other devices once there's a connection, so the app works offline with no code of its own. A value is anything JSON holds; values that change together go in one value. Calls are on [the reference page](https://tinylib.app/docs/reference/tinylib#tinylib-data).
### Changes from elsewhere
```js
tinylib.data.onChange((keys, error) => {
if (error) toast(error.message)
else if (keys.includes(open) && document.activeElement !== editor) editor.value = tinylib.data.get(open) ?? ''
})
```
A change from another device or from the rules calls `onChange` with its keys. When a save this page made fails on the device, `onChange` gets the error too, and those keys go back to what the store holds. When two devices change one key, the last write wins.
### When it's full
```js
try {
tinylib.data.set(open, editor.value)
} catch (error) {
toast(error.message)
}
```
`set` throws a TinylibError with `limit` when a value would take the persona's data past its cap, and a TypeError for a value JSON can't hold. Writes two devices made offline are both kept, even past the cap; after that, writes that add data throw until it's back under. The caps are under [Data](https://tinylib.app/docs/reference/limits#data).
### Values for friends
```js
tinylib.data.set('score', { day, seconds, streak }, { audience: 'friends' })
```
A value saved for friends is readable by the persona's friends who have the app. [Friends](https://tinylib.app/docs/guide/friends) reads them.
### Keys only the rules write
```json
{ "rulesKeys": ["wins", "games/"] }
```
```js
// rules.js
ctx.data(winner).set('wins', (ctx.data(winner).get('wins') ?? 0) + 1, { audience: 'friends' })
```
```js
// the page
const wins = tinylib.data.get('wins') ?? 0
```
A key `tinylib.json` declares in `rulesKeys`, or a key under a declared prefix ending in `/`, is written only by the rules, with [`ctx.data`](https://tinylib.app/docs/reference/rules#ctx). The page reads it like any other, and `set` and `remove` throw a TypeError for it. Declared keys are permanent: a publish can't drop one.
## Rooms and rules
```js
// rules.js
import { withViews } from 'tinylib-sdk/rules'
export const game = withViews({
address: 'game/:id',
create(options, ctx) {
return { board: Array(9).fill(''), x: ctx.by.id, o: null, next: 'x' }
},
on(state, input, ctx) {
if (input.type === 'join') {
if (state.o) ctx.refuse('This game already has two players.')
state.o = input.from.id
}
if (input.type === 'action' && input.name === 'move') {
const mark = input.from.id === state.x ? 'x' : 'o'
if (mark !== state.next) ctx.refuse("It's not your turn.")
if (state.board[input.data] !== '') ctx.refuse('Pick an empty square.')
state.board[input.data] = mark
state.next = mark === 'x' ? 'o' : 'x'
ctx.notify(mark === 'x' ? state.o : state.x, 'Your turn')
}
},
view: (state, member) => ({ board: state.board, you: member.id === state.x ? 'x' : 'o', next: state.next }),
status: (state, member) => {
const mine = (member.id === state.x ? 'x' : 'o') === state.next
return { text: mine ? 'Your turn' : 'Their turn', waiting: mine }
},
})
```
```js
// app.js
const { id } = await tinylib.rooms.create('game')
const room = await tinylib.rooms.open(id)
room.onMessage(({ view }) => draw(view))
await room.send('move', 4)
```
A room is state several people share, run by the app's rules on Tinylib's servers. Each export of `rules.js` is a kind of room, named by the export. `create` returns the first state; `on` gets every input, one at a time, changes the state in place and calls [`ctx`](https://tinylib.app/docs/reference/rules#ctx) for outputs. A refusal or a throw throws away the change and every output of that input.
[`withViews`](https://tinylib.app/docs/reference/rules#withviews) sends each member `{ view }` after every change and every time a page opens the room, and sets each member's status, the line Tinylib shows them about the room. A member sees only what the rules send them.
### Opening a room
```js
const room = await tinylib.rooms.open(id)
const stop = room.onMessage(({ view }) => draw(view))
room.onChange(() => room.ended && showEnded())
// on leaving the screen
stop()
room.close()
```
The persona is connected while a page has the room open on one of their devices. What the rules send reaches only members with the room open, once; messages that come before a listener is added wait for it. `open` rejects with `not_member`, `ended` or `offline`.
### Refusing
```js
try {
await room.send('move', square)
} catch (error) {
toast(error.message) // "It's not your turn."
}
```
`ctx.refuse(sentence)` stops the input, and `send` rejects with `refused` and that sentence. Creating a room, an action, a join, an invite, getting the link and deleting it can be refused. Leaving, connecting, disconnecting and opening are facts.
### Inviting and joining
```js
await tinylib.rooms.invite(id) // Tinylib's invite sheet, which also shares the link
await tinylib.rooms.invite(id, friend.id) // one persona
const { url, code } = await tinylib.rooms.getLink(id, { code: true })
const { id: joined } = await tinylib.rooms.join('K7QD')
```
Someone who takes an invitation or opens the link joins through Tinylib, which asks the rules with a `join` input. They aren't a member until the rules accept, so the rules welcome them on their `connect`, which follows. A kind's `address`, here `'game/:id'`, is where Tinylib sends people who come in for a room from outside the app: a notification, an invitation, the link. `:id` is the room's id.
A page takes the invitations it shows itself with [`tinylib.invitations.handle`](https://tinylib.app/docs/reference/tinylib#tinylib-invitations-handle), and Tinylib shows its own card for the rest.
### Members and names
```js
on(state, input, ctx) {
const names = ctx.members.map((member) => member.name)
const here = ctx.members.filter((member) => member.connected)
}
```
`ctx.members` lists the current members with their current names, and whether each has the room open. The rules keep ids in their state, never names. Someone who left isn't in `ctx.members`; their `leave` input carries their name.
### Time and randomness
```js
on(state, input, ctx) {
if (input.type === 'action' && input.name === 'start') {
state.deadline = ctx.now + 20_000
ctx.setTimer('close', state.deadline)
state.order = state.questions.map(() => ctx.random())
}
if (input.type === 'timer' && input.name === 'close') state.phase = 'reveal'
}
```
```js
const seconds = Math.ceil((view.deadline - tinylib.now()) / 1000)
```
`ctx.now` and `ctx.random()` come from Tinylib, so players can't rig them. A timer wakes the room with a `timer` input. The page counts down with `tinylib.now()`, on the same clock as `ctx.now`.
### Data a room leaves
```js
// rules.js
ctx.data.room.set('result', { winner, moves: state.moves })
```
```js
// the page, during the room or after it ended
const result = await tinylib.rooms.data(id).get('result')
```
`ctx.data.room` is kept with the room's record, for its members during the room and after. A member's own results go under keys the rules write, in [their data](https://tinylib.app/docs/guide/data#keys-only-the-rules-write).
### Ending and leaving
```js
await tinylib.rooms.leave(id)
```
`ctx.end()` ends the room for good. Each member keeps it in their past rooms, from `tinylib.rooms.list()`, with the status they had at the end. A member leaving is a `leave` input with `why`, and Tinylib closes a room that goes unused for `idleMs` on [Limits and errors](https://tinylib.app/docs/reference/limits#rooms).
### A new publish
A publish reloads every open page of the app, and each open room gets an `upgraded` input before its first input under the new rules. A publish can't remove a kind that still has open rooms: its `create` refuses new ones, and a later publish removes it once the last has ended.
## Friends
```json
{ "uses": ["friends"] }
```
```js
tinylib.data.set('score', { day, seconds }, { audience: 'friends' })
const [friends, scores] = await Promise.all([tinylib.friends.list(), tinylib.friends.data.get('score')])
const board = friends
.map((friend) => ({ name: friend.name, score: scores.get(friend.id)?.value }))
.filter((row) => row.score?.day === day)
```
`tinylib.friends.list()` gives the persona's friends who have the app, each `{ id, name }`. `tinylib.friends.data.get(key)` gives one key across every friend, as a `Map` of friend's id to `{ value, changedAt }`. Only values saved for friends are there, whether the friend's page or the rules wrote them. Both calls need a connection and reject with `offline` without one. Friends' values are read again when the page wants them; nothing calls back when they change.
### One friend
```js
const store = tinylib.friends.data.of(friend.id)
const recent = await store.entries('day/')
```
`of` reads one friend's values with `tinylib.data`'s verbs, each async. It's empty for anyone who isn't a friend with the app.
### Sending the app
```js
if (friends.length === 0) await tinylib.friends.sendApp()
```
`sendApp` opens Tinylib's Send sheet, where the person picks friends to send the app to. It resolves when the sheet closes, and the app never learns who was picked.
### Names, blocking and reporting
```js
blockButton.onclick = async () => {
if (await tinylib.block(friend.id)) showBoard()
}
reportButton.onclick = () => tinylib.report(friend.id)
```
An id is named by the list it came from: a friend by `friends.list()`, a room's member by `rooms.list()` or `ctx.members`. Those lists carry current names and leave out people who are blocked or whose persona ended. `block` and `report` open Tinylib's own sheets and resolve true if the person went through with it, false if they backed out. Blocking a friend ends the friendship.
## Reminders and notifications
```json
{ "uses": ["notifications"] }
```
```js
await tinylib.reminders.set('evening', {
title: 'Time to write',
text: 'How was today?',
repeat: tinylib.reminders.daily('18:00'),
address: 'today',
})
```
`reminders.set` schedules a notification for the persona on each of their devices that receive notifications. It goes off at its time with the app closed, and a reminder set without a connection still goes off on that device. Each reminder has a name: setting a name again replaces that reminder, so two devices that set `'evening'` end up with one. A tap on it opens the app at `address`, or the start screen for `''`.
### Once or repeating
```js
await tinylib.reminders.set('dentist', { title: 'Dentist at 3', at: '2026-10-06T14:30' })
await tinylib.reminders.set('tea', { title: 'Tea is ready', at: tinylib.now() + 4 * 60_000 })
await tinylib.reminders.set('standup', { title: 'Standup', repeat: tinylib.reminders.weekly(['mon', 'tue', 'wed', 'thu', 'fri'], '09:30') })
```
`at` is a local date and time, or a time on Tinylib's clock. `repeat` comes from `daily`, `weekly` or `monthly` and starts at the next matching time. A reminder keeps the time zone it was set in, across travel and clock changes; `list()` gives it as `timeZone`. On the web, a reminder goes off without a connection only while Tinylib is open.
### The notifications switch
```js
offNotice.hidden = tinylib.persona.notificationsOn
turnOn.onclick = () => tinylib.persona.edit('notifications')
try {
await tinylib.reminders.set('evening', evening)
} catch (error) {
if (error.code === 'notifications_off') offNotice.hidden = false
else toast(error.message)
}
```
Only the person changes the app's notifications switch. The app reads `notificationsOn`, and `persona.edit('notifications')` opens Tinylib's card for it. Setting a reminder with the switch off asks the person to turn it on; left off, the reminder isn't set and `set` rejects with `notifications_off`.
### Turned back on
```js
tinylib.persona.onChange(async () => {
if (!tinylib.persona.notificationsOn) return
const reminders = await tinylib.reminders.list()
if (!reminders.some((reminder) => reminder.name === 'evening')) await tinylib.reminders.set('evening', evening)
})
```
Turning the switch off cancels every waiting reminder of the app, on every device. When it's back on, the app sets them again.
### Notifications from a room
```js
// rules.js
ctx.notify(next, 'Your turn', `${input.from.name} played.`)
ctx.status(next, 'Your turn', true)
```
`ctx.notify` reaches a member's devices only when they don't have the room open; Tinylib applies that. A notification stacks, and a status replaces the last one, so whose turn it is goes in the status, and an event, such as a move made, goes in a notification. Both follow the persona's notifications switch, and `notify` needs `"notifications"` in `uses`.
## Places and links
```js
function route() {
const [path, query] = tinylib.places.address.split('?')
const [screen, id] = path.split('/')
if (screen === 'game' && id) return showGame(id)
if (screen === 'settings') return showSettings(new URLSearchParams(query))
showStart()
}
tinylib.places.onChange(route)
route()
```
```js
tinylib.places.go(`game/${id}`)
tinylib.places.go('', { replace: true })
```
An address is the page's own path without its first `/`, with any `?` part: `game/abc?tab=moves`. `''` is the start screen. The `#` part stays inside the page. `onChange` runs after every move: the page's own, back and forward, and Tinylib landing the person on an address. Since the address is the page's path, a router such as React Router works as it is, in place of `tinylib.places`.
### Links
```html
Settings
Back to the game
The rules of chess
```
A plain `` to an address in the app moves there without loading a page, as `places.go` does. A link anywhere else opens outside Tinylib. A page that reloads itself is opened again at its address.
### Sharing a place
```js
await tinylib.places.share(`list/${id}`, 'Our shopping list')
```
`share` opens Tinylib's share sheet with a link to that place in the app, which opens the app at that address.
### Where people arrive from outside
```js
// rules.js
export const game = withViews({ address: 'game/:id', create, on, view, status })
export const list = withViews({ address: '?list=:id', create, on, view, status })
```
A notification, an invitation, a room's link and a tap on a room in Tinylib's own lists send the person to the kind's `address`, with `:id` filled in with the room's id. A kind with no `address` sends them to the start screen, and the app isn't told which room. A reminder lands on its own `address`.
## Errors
```js
try {
const { id } = await tinylib.rooms.join(code)
tinylib.places.go(`game/${id}`)
} catch (error) {
if (error.code === 'not_found') codeField.setCustomValidity(error.message)
else toast(error.message)
}
```
A call that didn't do what it asked rejects, or throws if it's synchronous, with a TinylibError: a `code` from [ErrorCode](https://tinylib.app/docs/reference/limits#errorcode) and a `message` written to show the person. A call given arguments it can't take throws or rejects with a TypeError. A rejection nobody catches shows only in the console, and in `tinylib dev`.
### Sheets resolve
```js
if (await tinylib.block(opponent.id)) tinylib.places.go('')
await tinylib.friends.sendApp()
```
A call that opens one of Tinylib's sheets or cards resolves when it closes, whatever the person chose there. Backing out isn't an error: `block` and `report` resolve false, and `persona.edit` resolves with the switch unchanged.
### Without a connection
```js
tinylib.connection.onChange(() => (offline.hidden = tinylib.connection.online))
```
`tinylib.data` and `tinylib.reminders` work without a connection. Opening a room, and the calls that reach Tinylib's servers, reject with `offline`. An open room drops with the connection and opens again when it's back.
### Errors in the rules
```js
on(state, input, ctx) {
if (input.type === 'action' && input.name === 'move') {
if (!state.players.includes(input.from.id)) ctx.refuse('You are watching this game.')
}
}
```
`ctx.refuse` is the rules saying no: the member who acted gets the sentence. A throw in the rules changes nothing and sends nothing: a request is refused with Tinylib's own sentence, any other input is dropped, and the room carries on. `tinylib dev` shows every input, output and error as it happens.
## tinylib
The object Tinylib puts in every page of an app before the page's own scripts run, with the persona and their data
loaded.
**Properties**
| Property | Type |
| ------ | ------ |
| `persona` | [`Persona`](https://tinylib.app/docs/reference/tinylib#tinylib-persona) |
| `connection` | [`Connection`](https://tinylib.app/docs/reference/tinylib#tinylib-connection) |
| `places` | [`Places`](https://tinylib.app/docs/reference/tinylib#tinylib-places) |
| `data` | [`Data`](https://tinylib.app/docs/reference/tinylib#tinylib-data) |
| `friends` | [`Friends`](https://tinylib.app/docs/reference/tinylib#tinylib-friends) |
| `rooms` | [`Rooms`](https://tinylib.app/docs/reference/tinylib#tinylib-rooms) |
| `invitations` | [`Invitations`](https://tinylib.app/docs/reference/tinylib#tinylib-invitations) |
| `reminders` | [`Reminders`](https://tinylib.app/docs/reference/tinylib#tinylib-reminders) |
### tinylib.persona
The person inside this app, always current.
**Properties**
| Property | Type | Description |
| ------ | ------ | ------ |
| `id` | `string` | Only this app sees it: the same person has a different id in every app. |
| `name` | `string` | The person's name, the one they gave Tinylib. They change it on their profile in Tinylib, not in an app. |
| `notificationsOn` | `boolean` | Whether this app's notifications are on, the one setting an app can ask about. |
#### tinylib.persona.onChange()
```ts
onChange(fn): () => void;
```
Calls `fn` after the name or the notifications switch changes. Returns a function that stops it.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `fn` | () => `void` |
**Returns**
() => `void`
**Example**
```ts
tinylib.persona.onChange(() => (title.textContent = tinylib.persona.name))
```
#### tinylib.persona.edit()
```ts
edit(setting): Promise;
```
Opens Tinylib's card for this app's notifications switch, where the person changes it. Resolves with the switch's
value when the card closes, unchanged if the person backed out. Rejects with `offline`. Throws a TypeError for any
setting but `'notifications'`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `setting` | `"notifications"` |
**Returns**
`Promise`\<`boolean`\>
**Example**
```ts
button.onclick = async () => (button.hidden = await tinylib.persona.edit('notifications'))
```
### tinylib.connection
Whether Tinylib's servers can be reached. Rooms and the calls that reach the servers need them; `tinylib.data` doesn't.
**Properties**
| Property | Type |
| ------ | ------ |
| `online` | `boolean` |
#### tinylib.connection.onChange()
```ts
onChange(fn): () => void;
```
Calls `fn` after `online` changes. Returns a function that stops it.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `fn` | () => `void` |
**Returns**
() => `void`
**Example**
```ts
tinylib.connection.onChange(() => (offline.hidden = tinylib.connection.online))
```
### tinylib.now()
```ts
now(): number;
```
Milliseconds on Tinylib's clock, the clock the rules' `ctx.now` uses. `Date.now()` is the device's own.
**Returns**
`number`
**Example**
```ts
const left = view.deadline - tinylib.now()
```
### tinylib.places
Where the person is in the app. An address is the page's path without its first `/`, with any `?` part; the `#`
part stays in the page. An app with its own router uses the router instead.
**Properties**
| Property | Type | Description |
| ------ | ------ | ------ |
| `address` | `string` | The current place, '' for the start screen, with any `?` part. |
#### tinylib.places.go()
```ts
go(address, options?): void;
```
Moves to `address` without loading a page, as a link to it does. `replace` replaces the current place in the
history instead of adding one. Throws a TypeError for an address that starts with `/`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `address` | `string` |
| `options?` | \{ `replace?`: `boolean`; \} |
| `options.replace?` | `boolean` |
**Returns**
`void`
**Example**
```ts
tinylib.places.go(`game/${id}`)
```
#### tinylib.places.onChange()
```ts
onChange(fn): () => void;
```
Calls `fn` after any move: the page's own, back and forward, or Tinylib landing the person on an address. Returns
a function that stops it.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `fn` | () => `void` |
**Returns**
() => `void`
**Example**
```ts
tinylib.places.onChange(route)
```
#### tinylib.places.share()
```ts
share(address, text?): Promise;
```
Opens Tinylib's share sheet with a link to `address` in the app, and `text` with it. Resolves when the sheet
closes.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `address` | `string` |
| `text?` | `string` |
**Returns**
`Promise`\<`void`\>
**Example**
```ts
await tinylib.places.share(`list/${id}`, 'Our shopping list')
```
### tinylib.data
The persona's data in this app, kept on the device and synced to their other devices. Values are anything JSON
holds. Every call is synchronous.
#### tinylib.data.get()
```ts
get(key): DataValue | undefined;
```
The value under `key`, or `undefined`.
**Type Parameters**
| Type Parameter |
| ------ |
| `K` *extends* `string` |
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `key` | `K` |
**Returns**
[`DataValue`](https://tinylib.app/docs/reference/tinylib#datavalue)\<`K`\> \| `undefined`
#### tinylib.data.set()
```ts
set(
key,
value,
options?
): void;
```
Saves `value` under `key` on the device at once, and syncs it after. `audience: 'friends'` shows it to the
persona's friends who have the app; `'me'`, the default, keeps it to the persona. Throws a TypeError for a value
JSON can't hold or a key `tinylib.json` declares for the rules, and a TinylibError with `limit` past the data cap.
A save that fails after reaches `onChange` with its error.
**Type Parameters**
| Type Parameter |
| ------ |
| `K` *extends* `string` |
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `key` | `K` |
| `value` | [`DataValue`](https://tinylib.app/docs/reference/tinylib#datavalue)\<`K`\> |
| `options?` | \{ `audience?`: [`Audience`](https://tinylib.app/docs/reference/rules#audience); \} |
| `options.audience?` | [`Audience`](https://tinylib.app/docs/reference/rules#audience) |
**Returns**
`void`
**Example**
```ts
tinylib.data.set('best', 42, { audience: 'friends' })
```
#### tinylib.data.remove()
```ts
remove(key): void;
```
Removes the value under `key`. Throws a TypeError for a key `tinylib.json` declares for the rules.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `key` | `string` |
**Returns**
`void`
#### tinylib.data.entries()
```ts
entries(prefix?): [string, unknown][];
```
`[key, value]` for every key that starts with `prefix`, sorted by key. Without `prefix`, every key.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `prefix?` | `string` |
**Returns**
\[`string`, `unknown`\][]
**Example**
```ts
const entries = tinylib.data.entries('entry/')
```
#### tinylib.data.keys()
```ts
keys(prefix?): string[];
```
The keys that start with `prefix`, in `entries`' order.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `prefix?` | `string` |
**Returns**
`string`[]
#### tinylib.data.values()
```ts
values(prefix?): unknown[];
```
The values under the keys that start with `prefix`, in `entries`' order.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `prefix?` | `string` |
**Returns**
`unknown`[]
#### tinylib.data.onChange()
```ts
onChange(fn): () => void;
```
Calls `fn(keys)` after values change from another device or the rules, and `fn(keys, error)` after a save this
page made fails, with those keys back to what the store holds. Returns a function that stops it.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `fn` | (`keys`, `error?`) => `void` |
**Returns**
() => `void`
**Example**
```ts
tinylib.data.onChange((keys, error) => (error ? toast(error.message) : draw()))
```
### tinylib.friends
The persona's friends who have this app. Needs `"friends"` in `tinylib.json`'s `uses`.
**Properties**
| Property | Type | Description |
| ------ | ------ | ------ |
| `data` | `object` | Friends' values saved for friends, by their page or the rules. Every call rejects with `offline`. **Example** `const best = await tinylib.friends.data.get('best') // Map of friend's id to { value, changedAt }` |
| `data.get` | `Promise`\<`Map`\<`string`, [`FriendValue`](https://tinylib.app/docs/reference/tinylib#friendvalue)\>\> | - |
| `data.of` | [`RemoteStore`](https://tinylib.app/docs/reference/tinylib#remotestore)\<[`FriendValue`](https://tinylib.app/docs/reference/tinylib#friendvalue)\> | - |
#### tinylib.friends.list()
```ts
list(): Promise;
```
Resolves with the persona's friends who have the app. Rejects with `offline`.
**Returns**
`Promise`\<`object`[]\>
**Example**
```ts
const friends = await tinylib.friends.list()
```
#### tinylib.friends.sendApp()
```ts
sendApp(): Promise;
```
Opens Tinylib's Send sheet, where the person sends the app to friends. Resolves when it closes, without saying who
was picked. Rejects with `offline` or `limit`.
**Returns**
`Promise`\<`void`\>
#### FriendValue
A friend's value.
**Properties**
| Property | Type | Description |
| ------ | ------ | ------ |
| `value` | `unknown` | - |
| `changedAt` | `number` | When it last changed, on Tinylib's clock. |
#### RemoteStore
A read-only store that lives on Tinylib's servers, read with `tinylib.data`'s verbs, each async.
**Type Parameters**
| Type Parameter |
| ------ |
| `V` |
##### RemoteStore.get()
```ts
get(key): Promise;
```
Resolves with the value under `key`, or `undefined`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `key` | `string` |
**Returns**
`Promise`\<`V` \| `undefined`\>
##### RemoteStore.keys()
```ts
keys(prefix?): Promise;
```
Resolves with the keys that start with `prefix`, sorted.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `prefix?` | `string` |
**Returns**
`Promise`\<`string`[]\>
##### RemoteStore.values()
```ts
values(prefix?): Promise;
```
Resolves with the values under the keys that start with `prefix`, in `keys`' order.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `prefix?` | `string` |
**Returns**
`Promise`\<`V`[]\>
##### RemoteStore.entries()
```ts
entries(prefix?): Promise<[string, V][]>;
```
Resolves with `[key, value]` for every key that starts with `prefix`, sorted by key.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `prefix?` | `string` |
**Returns**
`Promise`\<\[`string`, `V`\][]\>
### tinylib.rooms
The persona's rooms in this app, each by its id. Acting in a room is on the room `open` resolves with.
#### tinylib.rooms.create()
```ts
create(kind, options?): Promise<{
id: string;
}>;
```
Creates a room of `kind`, the name of a `rules.js` export, with the persona as its first member and `options` passed
to the kind's `create`. Resolves with the room's id. Rejects with `refused` when the rules refuse it, `limit`, or
`offline`. Throws a TypeError for options JSON can't hold.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `kind` | `string` |
| `options?` | `unknown` |
**Returns**
`Promise`\<\{
`id`: `string`;
\}\>
**Example**
```ts
const { id } = await tinylib.rooms.create('game')
```
#### tinylib.rooms.open()
```ts
open(id): Promise>>;
```
Opens the room on this page and connects the persona to it. Resolves with the open room. Rejects with
`not_member`, `ended`, `offline` or `failed`.
**Type Parameters**
| Type Parameter | Default type |
| ------ | ------ |
| `K` *extends* `string` | `string` |
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `id` | `string` |
**Returns**
`Promise`\<[`OpenRoom`](https://tinylib.app/docs/reference/tinylib#room)\<[`RoomMessage`](https://tinylib.app/docs/reference/tinylib#roommessage)\<`K`\>\>\>
**Example**
```ts
const room = await tinylib.rooms.open(id)
```
#### tinylib.rooms.join()
```ts
join(code): Promise<{
id: string;
}>;
```
Joins the room a short code names. Resolves with its id. Rejects with `not_found` for a code no room of the app
has, `refused` when the rules refuse the join, `limit` after too many codes, or `offline`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `code` | `string` |
**Returns**
`Promise`\<\{
`id`: `string`;
\}\>
**Example**
```ts
const { id } = await tinylib.rooms.join('K7QD')
```
#### tinylib.rooms.invite()
```ts
invite(id, personaId?): Promise;
```
Invites `personaId`, a friend or someone from the persona's rooms. Without `personaId`, opens Tinylib's invite
sheet for the room, which also shares its link. Resolves once sent, or when the sheet closes. Inviting someone who
blocked the persona resolves as if sent. Rejects with `refused` when the rules refuse it, `not_member`, `ended`,
`limit`, or `offline`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `id` | `string` |
| `personaId?` | `string` |
**Returns**
`Promise`\<`void`\>
**Example**
```ts
await tinylib.rooms.invite(id, friend.id)
```
#### tinylib.rooms.getLink()
```ts
getLink(id, options?): Promise<{
url: string;
code: string | null;
}>;
```
Resolves with the room's one link, made if it has none, and its short code, or null. `code: true` adds a short
code to the same link if it has none. Rejects with `refused` when the rules refuse it, `not_member`, `ended`, or
`offline`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `id` | `string` |
| `options?` | \{ `code?`: `boolean`; \} |
| `options.code?` | `boolean` |
**Returns**
`Promise`\<\{
`url`: `string`;
`code`: `string` \| `null`;
\}\>
**Example**
```ts
const { url, code } = await tinylib.rooms.getLink(id, { code: true })
```
#### tinylib.rooms.deleteLink()
```ts
deleteLink(id): Promise;
```
Stops the room's link and its short code for good; `getLink` makes a new one. Rejects with `refused` when the rules
refuse it, `not_member`, `ended`, or `offline`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `id` | `string` |
**Returns**
`Promise`\<`void`\>
#### tinylib.rooms.leave()
```ts
leave(id): Promise;
```
Takes the persona out of the room, which stays in `list()` marked left. Rejects with `offline`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `id` | `string` |
**Returns**
`Promise`\<`void`\>
#### tinylib.rooms.list()
```ts
list(): Promise;
```
Resolves with the persona's rooms in this app: current ones, then past ones.
**Returns**
`Promise`\<[`RoomItem`](https://tinylib.app/docs/reference/tinylib#roomlistitem)[]\>
**Example**
```ts
const waiting = (await tinylib.rooms.list()).filter((room) => room.waiting)
```
#### tinylib.rooms.onChange()
```ts
onChange(fn): () => void;
```
Calls `fn` with the list after it changes. Returns a function that stops it.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `fn` | (`list`) => `void` |
**Returns**
() => `void`
#### tinylib.rooms.data()
```ts
data(roomId): RemoteStore;
```
The data the rules keep with the room, for its members during the room and after. Someone who left reads it as it
was when they left. Each call rejects with `not_member` for a room the persona was never in, or `offline`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `roomId` | `string` |
**Returns**
[`RemoteStore`](https://tinylib.app/docs/reference/tinylib#remotestore)\<`unknown`\>
**Example**
```ts
const moves = await tinylib.rooms.data(id).values('move/')
```
#### room
A room the page has open, from `tinylib.rooms.open`.
**Type Parameters**
| Type Parameter | Default type |
| ------ | ------ |
| `M` | `unknown` |
**Properties**
| Property | Type | Description |
| ------ | ------ | ------ |
| `id` | `string` | - |
| `kind` | `string` | The kind's name, the `rules.js` export it runs. |
| `ended` | `boolean` | - |
| `left` | `boolean` | True once the persona is no longer in the room. |
##### room.close()
```ts
close(): void;
```
Closes the room on this page. The persona stays connected while another page or device has it open.
**Returns**
`void`
##### room.send()
```ts
send(action, data?): Promise;
```
Sends the action `action`, with `data`, to the rules. Resolves once the rules accept it. Rejects with `refused` and
the rules' sentence when they refuse it, and with `ended`, `not_member`, `offline`, or `failed` once the page closed
the room. Throws a TypeError for an action that isn't a string, or data JSON can't hold.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `action` | `string` |
| `data?` | `unknown` |
**Returns**
`Promise`\<`void`\>
**Example**
```ts
await room.send('move', { square: 4 })
```
##### room.onMessage()
```ts
onMessage(fn): () => void;
```
Calls `fn` with everything the rules send this member. Messages that come while the room has no listener wait,
in order, for the next one added. Returns a function that stops it.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `fn` | (`message`) => `void` |
**Returns**
() => `void`
**Example**
```ts
room.onMessage(({ view }) => draw(view))
```
##### room.onChange()
```ts
onChange(fn): () => void;
```
Calls `fn` when the room ends or the persona is no longer in it. Returns a function that stops it.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `fn` | () => `void` |
**Returns**
() => `void`
#### RoomListItem
```ts
type RoomListItem = RoomItem;
```
One of the persona's rooms, as `rooms.list()` gives it.
**Properties**
| Property | Type | Description |
| ------ | ------ | ------ |
| `id` | `string` | - |
| `kind` | `string` | - |
| `name` | `string` \| `null` | The name the rules gave the room, or null until they do. |
| `status` | `string` \| `null` | The line the rules last set for the persona with `ctx.status`, or null. |
| `waiting` | `boolean` | Whether that status says the room waits on the persona. |
| `members` | [`Member`](https://tinylib.app/docs/reference/rules#member)[] | The current members; for a past room, the members at its end. |
| `ended` | `boolean` | - |
| `closed` | `boolean` | True for a room Tinylib closed because it sat unused or everyone left. Such a room shows no status. |
| `left` | `boolean` | True once the persona left or was removed. |
| `endedAt` | `number` \| `null` | When it ended, on Tinylib's clock, or null. |
### tinylib.invitations
The persona's invitations to rooms of this app.
#### tinylib.invitations.list()
```ts
list(): Promise;
```
Resolves with every invitation, without taking any from Tinylib's card.
**Returns**
`Promise`\<[`Invitation`](https://tinylib.app/docs/reference/tinylib#invitation)[]\>
#### tinylib.invitations.handle()
```ts
handle(pick, fn): () => void;
```
Takes the invitations `pick` returns true for: `fn` gets them now and after every change, and Tinylib shows its own
card only for those no handler takes. An invitation two handlers pick goes to both. Returns a function that stops
it. Throws a TypeError unless both are functions.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `pick` | (`invitation`) => `boolean` |
| `fn` | (`invitations`) => `void` |
**Returns**
() => `void`
**Example**
```ts
tinylib.invitations.handle((invitation) => invitation.from.id === opponent.id, showRematch)
```
#### invitation
An invitation to a room.
**Properties**
| Property | Type | Description |
| ------ | ------ | ------ |
| `id` | `string` | - |
| `from` | `object` | - |
| `from.id` | `string` | - |
| `from.name` | `string` | - |
| `kind` | `string` | - |
| `name` | `string` \| `null` | The room's name, or null until the rules give it one. |
| `at` | `number` | When it was sent, on Tinylib's clock. |
##### invitation.accept()
```ts
accept(): Promise<{
id: string;
}>;
```
Joins the room. Resolves with its id. Rejects with `refused` when the rules refuse the join, `ended`, or `offline`.
**Returns**
`Promise`\<\{
`id`: `string`;
\}\>
##### invitation.dismiss()
```ts
dismiss(): Promise;
```
Turns the invitation down. Rejects with `offline`.
**Returns**
`Promise`\<`void`\>
### tinylib.reminders
The persona's reminders in this app, each by its name. Needs `"notifications"` in `tinylib.json`'s `uses`.
#### tinylib.reminders.set()
```ts
set(name, options): Promise;
```
Sets the reminder `name`, replacing one set under that name before. With the app's notifications switch off,
Tinylib asks the person first. Rejects with `notifications_off` if they leave it off, and with `refused` or `limit`
for one Tinylib can't set. Throws a TypeError for options that don't have a title and exactly one of `at` and
`repeat`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `name` | `string` |
| `options` | [`ReminderOptions`](https://tinylib.app/docs/reference/tinylib#reminderoptions) |
**Returns**
`Promise`\<`void`\>
**Example**
```ts
await tinylib.reminders.set('evening', { title: 'Journal', text: 'How was today?', repeat: tinylib.reminders.daily('18:00') })
```
#### tinylib.reminders.daily()
```ts
daily(time): Repeat;
```
Every day at `time`, `'HH:MM'` where the reminder is set. Throws a TypeError for another time.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `time` | `string` |
**Returns**
[`Repeat`](https://tinylib.app/docs/reference/tinylib#repeat)
#### tinylib.reminders.weekly()
```ts
weekly(days, time): Repeat;
```
On each of `days` at `time`. Throws a TypeError for no days, a day twice, or a time that isn't `'HH:MM'`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `days` | [`Day`](https://tinylib.app/docs/reference/tinylib#day)[] |
| `time` | `string` |
**Returns**
[`Repeat`](https://tinylib.app/docs/reference/tinylib#repeat)
**Example**
```ts
tinylib.reminders.weekly(['mon', 'tue', 'wed', 'thu', 'fri'], '09:30')
```
#### tinylib.reminders.monthly()
```ts
monthly(day, time): Repeat;
```
On day `day`, 1 to 31, of every month at `time`. Throws a TypeError for another day or time.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `day` | `number` |
| `time` | `string` |
**Returns**
[`Repeat`](https://tinylib.app/docs/reference/tinylib#repeat)
#### tinylib.reminders.list()
```ts
list(): Promise;
```
Resolves with the persona's reminders in this app.
**Returns**
`Promise`\<[`ReminderItem`](https://tinylib.app/docs/reference/tinylib#reminder)[]\>
#### tinylib.reminders.cancel()
```ts
cancel(name): Promise;
```
Cancels the reminder `name`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `name` | `string` |
**Returns**
`Promise`\<`void`\>
#### ReminderOptions
**Properties**
| Property | Type | Description |
| ------ | ------ | ------ |
| `title` | `string` | - |
| `text?` | `string` | - |
| `at?` | `string` \| `number` | A local date and time such as '2026-10-06T18:00', or a time on Tinylib's clock. |
| `repeat?` | [`Repeat`](https://tinylib.app/docs/reference/tinylib#repeat) | From `daily`, `weekly` or `monthly`. A reminder has exactly one of `at` and `repeat`. |
| `address?` | `string` | Where the reminder lands; '' is the start screen. |
#### Reminder
```ts
type Reminder = ReminderItem;
```
One of the persona's reminders, as `reminders.list()` gives it.
**Properties**
| Property | Type | Description |
| ------ | ------ | ------ |
| `name` | `string` | - |
| `title` | `string` | - |
| `text` | `string` \| `null` | - |
| `address` | `string` | - |
| `at` | `string` \| `number` \| `null` | A local date and time such as '2026-10-06T18:00', or a time on Tinylib's clock; null when it repeats. |
| `repeat` | [`Repeat`](https://tinylib.app/docs/reference/tinylib#repeat) \| `null` | - |
| `timeZone` | `string` | The time zone it was set in. |
| `next` | `number` \| `null` | When it next goes off, on Tinylib's clock. |
#### Repeat
```ts
type Repeat =
| {
every: "day";
time: string;
}
| {
every: "week";
days: Day[];
time: string;
}
| {
every: "month";
day: number;
time: string;
};
```
#### Day
```ts
type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
```
### tinylib.block()
```ts
block(personaId): Promise;
```
Opens Tinylib's confirmation to block `personaId`. Resolves true if the person blocked them, false if they backed
out. Rejects with `offline`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `personaId` | `string` |
**Returns**
`Promise`\<`boolean`\>
**Example**
```ts
if (await tinylib.block(opponent.id)) tinylib.places.go('')
```
### tinylib.report()
```ts
report(personaId): Promise;
```
Opens Tinylib's report sheet for `personaId`. Resolves true if the person sent a report, false if they backed out.
Rejects with `offline` or `limit`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `personaId` | `string` |
**Returns**
`Promise`\<`boolean`\>
### Types
#### Register
Types an app's data and rooms, in one declaration in the app:
```ts
declare module 'tinylib-sdk' {
interface Register {
data: { score: number; [key: `entry/${string}`]: string }
rooms: { game: { view: GameView } } // what each kind's rules send
}
}
```
#### DataKey
```ts
type DataKey = keyof DataShape & string;
```
A key of `Register`'s `data`, or any string without one.
#### DataValue
```ts
type DataValue = K extends keyof DataShape ? DataShape[K] : unknown;
```
The value `Register`'s `data` gives a key, or `unknown`.
**Type Parameters**
| Type Parameter |
| ------ |
| `K` *extends* `string` |
#### RoomKind
```ts
type RoomKind = keyof RoomShape & string;
```
A kind of room `Register`'s `rooms` names, or any string without one.
#### RoomMessage
```ts
type RoomMessage = K extends keyof RoomShape ? RoomShape[K] : unknown;
```
The messages `Register`'s `rooms` gives a kind, or `unknown`.
**Type Parameters**
| Type Parameter |
| ------ |
| `K` *extends* `string` |
## Rules
### Kind
A kind of room, exported from rules.js under its name.
**Example**
```ts
export const game = {
create(options, ctx) {
return { board: Array(9).fill(''), x: ctx.by.id, o: null }
},
on(state, input, ctx) {
if (input.type === 'join' && state.o) ctx.refuse('This game already has two players.')
},
}
```
**Extended by**
- [`ViewKind`](https://tinylib.app/docs/reference/rules#viewkind)
**Type Parameters**
| Type Parameter | Default type |
| ------ | ------ |
| `State` | `any` |
| `Options` | `any` |
**Properties**
| Property | Type | Description |
| ------ | ------ | ------ |
| `address?` | `string` | Where Tinylib sends people who come in for a room of this kind from outside the app, such as 'game/:id'. |
#### Kind.create()
```ts
create(options, ctx): State;
```
Returns the first state, from the options `tinylib.rooms.create` passed. May refuse them with `ctx.refuse`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `options` | `Options` |
| `ctx` | [`CreateContext`](https://tinylib.app/docs/reference/rules#createcontext) |
**Returns**
`State`
#### Kind.on()
```ts
on(
state,
input,
ctx
): void;
```
Every input, one at a time. Changes `state` in place and calls `ctx` for outputs.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `state` | `State` |
| `input` | [`Input`](https://tinylib.app/docs/reference/rules#input) |
| `ctx` | [`Context`](https://tinylib.app/docs/reference/rules#ctx) |
**Returns**
`void`
#### Input
```ts
type Input =
| {
type: "action";
from: Member;
name: string;
data?: unknown;
}
| {
type: "join";
from: Member;
via: | {
invite: Member;
}
| "link";
}
| {
type: "invite";
from: Member;
}
| {
type: "getLink";
from: Member;
}
| {
type: "deleteLink";
from: Member;
}
| {
type: "leave";
from: Member;
why: LeaveReason;
}
| {
type: "connect";
from: Member;
}
| {
type: "disconnect";
from: Member;
}
| {
type: "open";
from: Member;
}
| {
type: "timer";
name: string;
data?: unknown;
}
| {
type: "upgraded";
fromPublish: number;
};
```
What happens in a room, one at a time. Requests can be refused with `ctx.refuse`: `action`, `join`, `invite`,
`getLink` and `deleteLink`. The rest are facts.
- `action`: a member's `room.send(name, data)`.
- `join`: someone asks to join, `via` the member who invited them or the room's link. They aren't a member until
the rules accept, so they're welcomed on their `connect`, which follows at once.
- `invite`: a member is about to invite someone. The rules never learn who.
- `getLink` and `deleteLink`: a member gets the room's link, or deletes it.
- `leave`: a member left, and `why`.
- `connect` and `disconnect`: a member's first device opened the room, or their last closed it.
- `open`: a page opened the room, on every open, a reload or a second device included.
- `timer`: a timer `ctx.setTimer` set came due.
- `upgraded`: the first input under a new publish's rules.
#### LeaveReason
```ts
type LeaveReason =
| "left"
| "removed"
| "blocked someone"
| "data deleted"
| "account gone"
| "suspended";
```
Why a member left. `'blocked someone'`: they blocked another member, and so left.
### ctx
What Tinylib hands the rules with every input, and the outputs they call. Outputs happen once `on` returns; when it
refuses or throws, the state and every output are thrown away.
**Extended by**
- [`CreateContext`](https://tinylib.app/docs/reference/rules#createcontext)
**Properties**
| Property | Type | Description |
| ------ | ------ | ------ |
| `roomId` | `string` | - |
| `kind` | `string` | The name of the kind's export. |
| `now` | `number` | When the input arrived, in milliseconds on Tinylib's clock. |
| `publish` | `number` | The publish that last wrote this state. |
| `members` | [`RoomMember`](https://tinylib.app/docs/reference/rules#roommember)[] | The current members in the order they joined, with their current names. |
| `data` | (`personaId`) => [`MemberValues`](https://tinylib.app/docs/reference/rules#membervalues) & `object` | `ctx.data(personaId)` is a member's values under the keys `tinylib.json` declares in `rulesKeys`, for a current member or one leaving on this input. `ctx.data.room` is the data kept with this room. Writes happen after the input, one by one, and a failed write undoes nothing. **Example** `ctx.data(winner).set('wins', (ctx.data(winner).get('wins') ?? 0) + 1, { audience: 'friends' }) ctx.data.room.set('result', { winner })` |
#### ctx.random()
```ts
random(): number;
```
A number from 0 up to 1 that players can't predict or rig.
**Returns**
`number`
#### ctx.refuse()
```ts
refuse(sentence): never;
```
Refuses the input: nothing changes, nothing is sent, and the member who acted gets `sentence`. Refusing a fact
changes nothing and tells no one.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `sentence` | `string` |
**Returns**
`never`
**Example**
```ts
if (state.o) ctx.refuse('This game already has two players.')
```
#### ctx.send()
```ts
send(to, message): void;
```
Sends `message` to the open app of each of `to`, once. It isn't kept: a member without the room open misses it.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `to` | [`Recipients`](https://tinylib.app/docs/reference/rules#recipients) |
| `message` | `unknown` |
**Returns**
`void`
**Example**
```ts
ctx.send('everyone', { screen: 'vote' })
```
#### ctx.notify()
```ts
notify(
id,
title,
text?
): void;
```
Sends a notification with `title` and `text` to the devices of member `id`, unless they have the room open. Needs
`"notifications"` in `tinylib.json`'s `uses`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `id` | `string` |
| `title` | `string` |
| `text?` | `string` |
**Returns**
`void`
**Example**
```ts
ctx.notify(next, 'Your move', `${input.from.name} played.`)
```
#### ctx.status()
```ts
status(
id,
text,
waiting
): void;
```
Sets the line Tinylib's screens show member `id` about this room, and whether the room waits on them. Tinylib keeps
it; the last one is the room's record when it ends.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `id` | `string` |
| `text` | `string` |
| `waiting` | `boolean` |
**Returns**
`void`
**Example**
```ts
ctx.status(next, 'Your move', true)
```
#### ctx.name()
```ts
name(text): void;
```
What Tinylib calls the room outside the app: in the person's rooms list, in an invitation, and in its own
sentences about the room. Until set, "this room". At most 60 characters.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `text` | `string` |
**Returns**
`void`
**Example**
```ts
ctx.name(options.title)
```
#### ctx.setTimer()
```ts
setTimer(
name,
at,
data?
): void;
```
Wakes the room at `at`, on Tinylib's clock, with a `timer` input carrying `name` and `data`. Setting a name again
moves it.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `name` | `string` |
| `at` | `number` |
| `data?` | `unknown` |
**Returns**
`void`
**Example**
```ts
ctx.setTimer('flag', ctx.now + 60_000)
```
#### ctx.cancelTimer()
```ts
cancelTimer(name): void;
```
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `name` | `string` |
**Returns**
`void`
#### ctx.remove()
```ts
remove(id): void;
```
Removes member `id`, which comes back as a `leave` input with `why: 'removed'`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `id` | `string` |
**Returns**
`void`
#### ctx.end()
```ts
end(): void;
```
Ends the room now. It takes no more input, and its members keep a record of it in their past rooms.
**Returns**
`void`
#### Recipients
```ts
type Recipients = "sender" | "everyone" | string | string[];
```
`to` for `ctx.send`: a member's id, a list of ids, everyone, or the input's sender.
#### MemberValues
A member's values under the keys `tinylib.json` declares in `rulesKeys`, from `ctx.data(personaId)`.
##### MemberValues.get()
```ts
get(key): unknown;
```
The current value under `key`, earlier writes in this input included. Throws for a key `rulesKeys` doesn't declare.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `key` | `string` |
**Returns**
`unknown`
##### MemberValues.keys()
```ts
keys(prefix?): string[];
```
The declared keys that start with `prefix`, sorted.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `prefix?` | `string` |
**Returns**
`string`[]
##### MemberValues.set()
```ts
set(
key,
value,
options?
): void;
```
Writes `value` under `key` after the input, for `audience`, `'me'` by default.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `key` | `string` |
| `value` | `unknown` |
| `options?` | \{ `audience?`: [`Audience`](https://tinylib.app/docs/reference/rules#audience); \} |
| `options.audience?` | [`Audience`](https://tinylib.app/docs/reference/rules#audience) |
**Returns**
`void`
##### MemberValues.remove()
```ts
remove(key): void;
```
Removes the value under `key` after the input.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `key` | `string` |
**Returns**
`void`
#### RoomData
The data kept with this room, for its members during the room and after, from `ctx.data.room`.
##### RoomData.get()
```ts
get(key): unknown;
```
The current value under `key`, earlier writes in this input included.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `key` | `string` |
**Returns**
`unknown`
##### RoomData.keys()
```ts
keys(prefix?): string[];
```
The keys that start with `prefix`, sorted.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `prefix?` | `string` |
**Returns**
`string`[]
##### RoomData.set()
```ts
set(key, value): void;
```
Writes `value` under `key` after the input.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `key` | `string` |
| `value` | `unknown` |
**Returns**
`void`
##### RoomData.remove()
```ts
remove(key): void;
```
Removes the value under `key` after the input.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `key` | `string` |
**Returns**
`void`
#### Audience
```ts
type Audience = "me" | "friends";
```
Who reads a value: the persona only, or also their friends who have the app.
### CreateContext
What Tinylib hands the rules with every input, and the outputs they call. Outputs happen once `on` returns; when it
refuses or throws, the state and every output are thrown away.
**Extends**
- [`Context`](https://tinylib.app/docs/reference/rules#ctx)
**Properties**
| Property | Type | Description | Inherited from |
| ------ | ------ | ------ | ------ |
| `roomId` | `string` | - | [`Context`](https://tinylib.app/docs/reference/rules#ctx).[`roomId`](https://tinylib.app/docs/reference/rules#ctx) |
| `kind` | `string` | The name of the kind's export. | [`Context`](https://tinylib.app/docs/reference/rules#ctx).[`kind`](https://tinylib.app/docs/reference/rules#ctx) |
| `now` | `number` | When the input arrived, in milliseconds on Tinylib's clock. | [`Context`](https://tinylib.app/docs/reference/rules#ctx).[`now`](https://tinylib.app/docs/reference/rules#ctx) |
| `publish` | `number` | The publish that last wrote this state. | [`Context`](https://tinylib.app/docs/reference/rules#ctx).[`publish`](https://tinylib.app/docs/reference/rules#ctx) |
| `members` | [`RoomMember`](https://tinylib.app/docs/reference/rules#roommember)[] | The current members in the order they joined, with their current names. | [`Context`](https://tinylib.app/docs/reference/rules#ctx).[`members`](https://tinylib.app/docs/reference/rules#ctx) |
| `data` | (`personaId`) => [`MemberValues`](https://tinylib.app/docs/reference/rules#membervalues) & `object` | `ctx.data(personaId)` is a member's values under the keys `tinylib.json` declares in `rulesKeys`, for a current member or one leaving on this input. `ctx.data.room` is the data kept with this room. Writes happen after the input, one by one, and a failed write undoes nothing. **Example** `ctx.data(winner).set('wins', (ctx.data(winner).get('wins') ?? 0) + 1, { audience: 'friends' }) ctx.data.room.set('result', { winner })` | [`Context`](https://tinylib.app/docs/reference/rules#ctx).[`data`](https://tinylib.app/docs/reference/rules#ctx) |
| `by` | [`Member`](https://tinylib.app/docs/reference/rules#member) | The creator, who is the room's first member. | - |
### Member
A persona: its id, and its current display name.
**Extended by**
- [`RoomMember`](https://tinylib.app/docs/reference/rules#roommember)
**Properties**
| Property | Type |
| ------ | ------ |
| `id` | `string` |
| `name` | `string` |
### RoomMember
A current member, as `ctx.members` lists them.
**Extends**
- [`Member`](https://tinylib.app/docs/reference/rules#member)
**Properties**
| Property | Type | Description | Inherited from |
| ------ | ------ | ------ | ------ |
| `id` | `string` | - | [`Member`](https://tinylib.app/docs/reference/rules#member).[`id`](https://tinylib.app/docs/reference/rules#member) |
| `name` | `string` | - | [`Member`](https://tinylib.app/docs/reference/rules#member).[`name`](https://tinylib.app/docs/reference/rules#member) |
| `joinedAt` | `number` | - | - |
| `connected` | `boolean` | True while they have the room open on at least one device. | - |
### withViews()
```ts
function withViews(kind): Kind;
```
After `create`, after every input the rules don't refuse, and on `open`, sends each member
`{ view }` and sets their status. A kind's `end()` waits until those have gone out, so the last
status becomes the room's record.
**Type Parameters**
| Type Parameter | Default type |
| ------ | ------ |
| `State` | - |
| `View` | - |
| `Options` | `any` |
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `kind` | [`ViewKind`](https://tinylib.app/docs/reference/rules#viewkind)\<`State`, `View`, `Options`\> |
**Returns**
[`Kind`](https://tinylib.app/docs/reference/rules#kind)\<`State`, `Options`\>
**Example**
```ts
export const game = withViews({
create: (options, ctx) => ({ board: Array(9).fill(''), x: ctx.by.id, o: null, next: ctx.by.id }),
on(state, input, ctx) {},
view: (state, member) => ({ board: state.board, mine: state.next === member.id }),
status: (state, member) => (state.next === member.id ? { text: 'Your move', waiting: true } : { text: 'Their move', waiting: false }),
})
```
#### ViewKind
A kind of room, exported from rules.js under its name.
**Example**
```ts
export const game = {
create(options, ctx) {
return { board: Array(9).fill(''), x: ctx.by.id, o: null }
},
on(state, input, ctx) {
if (input.type === 'join' && state.o) ctx.refuse('This game already has two players.')
},
}
```
**Extends**
- [`Kind`](https://tinylib.app/docs/reference/rules#kind)\<`State`, `Options`\>
**Type Parameters**
| Type Parameter |
| ------ |
| `State` |
| `View` |
| `Options` |
**Properties**
| Property | Type | Description | Inherited from |
| ------ | ------ | ------ | ------ |
| `address?` | `string` | Where Tinylib sends people who come in for a room of this kind from outside the app, such as 'game/:id'. | [`Kind`](https://tinylib.app/docs/reference/rules#kind).[`address`](https://tinylib.app/docs/reference/rules#kind) |
##### ViewKind.create()
```ts
create(options, ctx): State;
```
Returns the first state, from the options `tinylib.rooms.create` passed. May refuse them with `ctx.refuse`.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `options` | `Options` |
| `ctx` | [`CreateContext`](https://tinylib.app/docs/reference/rules#createcontext) |
**Returns**
`State`
**Inherited from**
[`Kind`](https://tinylib.app/docs/reference/rules#kind).[`create`](https://tinylib.app/docs/reference/rules#kind-create)
##### ViewKind.on()
```ts
on(
state,
input,
ctx
): void;
```
Every input, one at a time. Changes `state` in place and calls `ctx` for outputs.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `state` | `State` |
| `input` | [`Input`](https://tinylib.app/docs/reference/rules#input) |
| `ctx` | [`Context`](https://tinylib.app/docs/reference/rules#ctx) |
**Returns**
`void`
**Inherited from**
[`Kind`](https://tinylib.app/docs/reference/rules#kind).[`on`](https://tinylib.app/docs/reference/rules#kind-on)
##### ViewKind.view()
```ts
view(
state,
member,
ctx
): View;
```
What this member is sent.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `state` | `State` |
| `member` | [`Member`](https://tinylib.app/docs/reference/rules#member) |
| `ctx` | [`Context`](https://tinylib.app/docs/reference/rules#ctx) |
**Returns**
`View`
##### ViewKind.status()
```ts
status(
state,
member,
ctx
): Status;
```
The line Tinylib shows this member outside the app, and whether the room waits on them.
**Parameters**
| Parameter | Type |
| ------ | ------ |
| `state` | `State` |
| `member` | [`Member`](https://tinylib.app/docs/reference/rules#member) |
| `ctx` | [`Context`](https://tinylib.app/docs/reference/rules#ctx) |
**Returns**
[`Status`](https://tinylib.app/docs/reference/rules#status)
#### Status
**Properties**
| Property | Type |
| ------ | ------ |
| `text` | `string` |
| `waiting` | `boolean` |
## tinylib.json
An app's `tinylib.json`, in its folder. `tinylib dev`, `preview` and `publish` check it, and refuse a field Tinylib
doesn't know. `encrypted`, `rulesKeys` and `uses` may be left out.
**Example**
```ts
{ "slug": "chess", "name": "Chess", "description": "Live chess with a friend.", "icon": "icon.svg", "uses": ["notifications"] }
```
**Properties**
| Property | Type | Description |
| ------ | ------ | ------ |
| `slug` | `string` | The readable name in the app's links and its subdomain: lowercase letters and digits, single hyphens between. The first publish claims it. |
| `name` | `string` | - |
| `description` | `string` | - |
| `icon` | `string` | The path of an image in the app's folder: `.svg`, `.png`, `.webp`, `.jpg` or `.jpeg`. |
| `encrypted` | `boolean` | Set at the first publish and never changed: a later publish with another value is refused. An app with encryption has no `rules.js` and no values for friends, and its data is readable only on people's own devices. False when left out. |
| `rulesKeys` | `string`[] | The keys of `tinylib.data` only the rules write: a key, or a prefix ending in `/` for every key under it. The page can't write them. A publish can't drop one, or declare one the page has written. |
| `uses` | (`"friends"` \| `"notifications"`)[] | The switches the app uses, which the app sheet asks about at the first open. `tinylib dev` warns, and `tinylib publish` refuses, when the app's code uses one not listed. |
### Use
```ts
type Use = typeof USES[number];
```
### USES
```ts
const USES: readonly ["notifications", "friends"];
```
What `uses` may list.
## Command line
```text
Usage: tinylib
init [folder] Makes a new app in the folder, which is empty or new, asking for what isn't given
--template journal or tictactoe
--name , --slug , --description tinylib.json's fields
dev [folder] Runs the app in a local shell, with simulated people
--port the port (4400)
--no-open doesn't open the browser
login Logs this computer in to publish, approved in the browser
--no-open prints the address without opening it
publish [folder] Publishes the app, logging this computer in first if it isn't
preview [folder] Publishes the app as its preview, open only to you and the testers you invite
--tester invites a tester, who's emailed the link; give it once per tester
--end ends the preview now
login, publish and preview talk to https://tinylib.app, or to $TINYLIB_URL.
```
## Limits and errors
### tinylib.json
| Limit | Value | What it limits |
| ------ | ------ | ------ |
| `slugChars` | 40 characters | `slug`, in characters. |
| `appNameChars` | 60 characters | `name`, in characters. |
| `descriptionChars` | 120 characters | `description`, in characters. |
| `pathChars` | 255 characters | A file's path in a publish, `icon`'s included, in characters. |
| `rulesKeys` | 100 | Keys `rulesKeys` declares. Each is at most MAX_KEY_BYTES. |
### Data
| Limit | Value | What it limits |
| ------ | ------ | ------ |
| `pageBytes` | 1 MB | What a persona's page may write, as UTF-8 keys and JSON values. |
| `rulesBytes` | 64 KB | What the rules may write for one persona, apart from the page's allowance. |
| `MAX_KEY_BYTES` | 128 bytes | One key's most bytes as UTF-8, from the page or the rules, or declared in tinylib.json. A longer key is refused. |
### Rooms
| Limit | Value | What it limits |
| ------ | ------ | ------ |
| `roomsJoined` | 20 | Rooms a persona is in at once, open and not left. Making or joining one more is refused with `limit`. |
| `members` | 16 | Current members of one room. A join past it is refused with `limit`. |
| `stateBytes` | 128 KB | The room's state and its timers, as JSON. An input that leaves them bigger is an error. |
| `roomDataBytes` | 256 KB | The data kept with the room, as JSON. A write past it fails alone. |
| `optionsBytes` | 16 KB | The options a room is made with, as UTF-8 JSON. Bigger ones are refused with `limit` before the rules hear them. |
| `actionBytes` | 16 KB | One action's data, as UTF-8 JSON. A bigger one is refused with `limit` before the rules hear it. |
| `sendBytes` | 64 KB | One message the rules send with `ctx.send`, as UTF-8 JSON. A bigger one is an error and reaches nobody. |
| `messagesPerSecond` | 30 | Actions members send into one room in any second. One past it is refused with `limit` and reaches nothing. Leaving, connecting and opening are facts, so they never count. |
| `statusChars` | 200 characters | One member's status text, in characters. A longer status is an error and isn't set. |
| `nameChars` | 60 characters | The room's name, in characters. A longer name is an error and isn't set. |
| `refusalChars` | 300 characters | The sentence the rules refuse with, in characters. A longer one is cut. |
| `disconnectWaitMs` | 5 seconds | How long after a member's last page closes the room before they're reported disconnected. |
| `idleMs` | 7 days | How long a room can go without any input before Tinylib closes it. |
| `linkMs` | 7 days | How long a room's link works after it's made. |
| `rulesCpuMs` | 50 milliseconds | The CPU time one call of an app's rules may take: one create or one input. Past it, the call is an error. |
| `roomCreate` | 60 per hour | Rooms one person makes. |
| `roomCode` | 10 per minute | Rooms joined by short code by one person, so codes can't be found by trying them. A token can't be guessed. |
| `invite` | 60 per hour | Personal invites to rooms sent by one person, from any of their personas. |
### Notifications
| Limit | Value | What it limits |
| ------ | ------ | ------ |
| `noticeTitleChars` | 80 characters | A notice's title, in characters. A longer one is cut. |
| `noticeTextChars` | 300 characters | A notice's text, in characters. A longer one is cut. |
| `roomNoticeToMember` | 60 per hour | A room's notices to one of its members, so one room can't flood a person. Counted per room and member. |
| `roomNotice` | 300 per hour | A room's notices to all its members together. Counted per room. |
### Reminders
| Limit | Value | What it limits |
| ------ | ------ | ------ |
| `waiting` | 32 | Reminders one persona keeps on a device. Setting a new name past it is refused with `limit`; replacing one isn't. |
| `reminder` | 12 per hour | A persona's reminders pushed to its person. Counted per persona. |
### Friends
| Limit | Value | What it limits |
| ------ | ------ | ------ |
| `friendsPerPerson` | 200 | The most friends one person can have. A request or a link that would pass it is refused. |
| `sendTo` | 50 | Friends one Send reaches at most. The rest of a longer list are passed over. |
| `sendApp` | 20 per day | Friends told of an app one person sends with Send. Past it, the app is still sent, and no notification goes. |
| `report` | 20 per day | Reports of people one person makes, against mass reporting. |
### Errors
#### ErrorCode
```ts
type ErrorCode =
| "refused"
| "failed"
| "offline"
| "limit"
| "not_member"
| "ended"
| "not_found"
| "notifications_off";
```
What a failed call failed with, as `error.code`.
- `refused`: Tinylib or the rules said no, and the message says why.
- `failed`: something went wrong on the way.
- `offline`: the call needs a connection, and there is none.
- `limit`: past a limit or a rate.
- `not_member`: the persona isn't in the room.
- `ended`: the room has ended, or the persona's data in the app was deleted.
- `not_found`: no room, code, link or persona by that name.
- `notifications_off`: the person, asked while a reminder was set, left the app's notifications off.
**Example**
```ts
try {
await tinylib.rooms.join(code)
} catch (error) {
if (error.code === 'not_found') codeField.invalid = true
else toast(error.message)
}
```
#### TinylibError
What a failed call rejects with, or a synchronous call throws. Its `message` is a sentence to show the person.
**Extends**
- `Error`
**Properties**
| Property | Type |
| ------ | ------ |
| `code` | [`ErrorCode`](https://tinylib.app/docs/reference/limits#errorcode) |