Architecture
Oyla is deliberately small: one Node.js process serves the web app, the REST API and the Socket.IO connections, and stores everything in one SQLite file.
Phones ──────────┐ Presenter view ──┼── HTTP + Socket.IO ──► Node.js (Fastify) ──► SQLite file Editor ──────────┘ also serves the web appThe editor talks to the REST API. The presenter screen and the phones hold a Socket.IO connection for everything live. The server is the source of truth: clients render what it sends and keep no state it hasn’t confirmed.
Repository layout
Section titled “Repository layout”The repository is an npm workspace with three packages.
| Path | Contents |
|---|---|
packages/shared |
The contract between both sides: zod schemas for REST bodies, the typed Socket.IO protocol, error codes, limits, the presentation themes and the question type definitions |
packages/server |
Fastify REST API, Socket.IO live layer, SQLite through Drizzle ORM, retention job |
packages/web |
React app (React Router, Vite, Tailwind CSS): pages, question type components, i18n |
e2e |
One Playwright test of the whole live flow |
docs-site |
This documentation (Astro Starlight, installed separately) |
shared is used as TypeScript source: Vite compiles it into the web bundle and tsup bundles it into
the server build, so it has no build step of its own.
| Concern | Choice |
|---|---|
| Runtime | Node.js 24, TypeScript in strict mode |
| HTTP | Fastify 5 with @fastify/rate-limit, @fastify/helmet and @fastify/static |
| Realtime | Socket.IO 4 with typed events |
| Database | SQLite through better-sqlite3 and Drizzle ORM; migrations generated by drizzle-kit |
| Validation | zod, shared by REST bodies and socket payloads |
| Web | React, React Router, Vite, Tailwind CSS, qrcode, d3-cloud |
| Tests | Vitest for all packages, Playwright end to end |
| Lint and format | Biome |
Data model
Section titled “Data model”Three tables, defined in packages/server/src/db/schema.ts. Deleting a presentation cascades to
its questions and their responses.
presentations id TEXT PK random 12-character id join_code TEXT UNIQUE 6 digits, shown as "123 456" title TEXT 1 to 120 characters theme TEXT colour theme key, default 'emerald' admin_token_hash TEXT SHA-256 of the admin token, hex active_question_id TEXT NULL the question the audience sees created_at, updated_at, last_activity_at INTEGER (Unix ms)
questions id TEXT PK presentation_id TEXT FK → presentations, ON DELETE CASCADE position INTEGER 0-based order type TEXT question type key, e.g. "multiple-choice" title TEXT up to 200 characters config TEXT (JSON) validated by the type's config schema created_at, updated_at
responses id INTEGER PK AUTOINCREMENT question_id TEXT FK → questions, ON DELETE CASCADE participant_id TEXT random id chosen by the participant's browser payload TEXT (JSON) validated by the type's response schema created_at INTEGER INDEX (question_id, participant_id)active_question_idis stored, so a restart resumes the presentation where it was.themeis one of the keys ofTHEMESinpackages/shared/src/themes.ts, which also exports theThemeKeytype andDEFAULT_THEME:emerald(the default),indigo,navyandblue. The column isTEXT NOT NULL DEFAULT 'emerald'and was added by a Drizzle migration, so older presentations get the default theme.- Editing a question never deletes responses. Results are computed from the stored answers and the current config each time, which is why votes for removed options or out-of-range ratings simply stop counting.
- The database runs in WAL mode with foreign keys on. Migrations in
packages/server/drizzleare applied at startup.
REST API
Section titled “REST API”JSON over HTTP under /api, defined in packages/server/src/http/routes.ts. Routes marked
“admin” need the header Authorization: Bearer <adminToken>.
| Method and path | Auth | Purpose |
|---|---|---|
GET /api/health |
none | Liveness: { "status": "ok" } |
GET /api/config |
none | { createPasswordRequired, retentionDays } |
POST /api/presentations |
create password, if set | Body { title, password? }. Returns 201 with { id, joinCode, adminToken } |
GET /api/presentations/:id |
admin | The presentation (PresentationDto, including its theme) with its questions and answer counts |
PATCH /api/presentations/:id |
admin | Rename or change the theme: { title?, theme? }, at least one. Live clients receive presentation |
DELETE /api/presentations/:id |
admin | Delete. Live clients receive presentation:ended. Returns 204 |
POST /api/presentations/:id/questions |
admin | Add a question: { type, title?, config? }. Returns 201 |
PUT /api/presentations/:id/questions/order |
admin | Reorder: { ids }, the full new order |
PATCH /api/presentations/:id/questions/:questionId |
admin | Update { title?, config? } |
DELETE /api/presentations/:id/questions/:questionId |
admin | Delete a question. Returns 204 |
DELETE /api/presentations/:id/questions/:questionId/responses |
admin | Reset results. Returns 204 |
Errors have one shape:
{ "error": { "code": "JOIN_CODE_NOT_FOUND", "message": "No presentation with this code" } }The codes live in packages/shared/src/errors.ts: VALIDATION_FAILED, NOT_FOUND,
UNAUTHORIZED, CREATE_PASSWORD_REQUIRED, CREATE_PASSWORD_INVALID, RATE_LIMITED,
JOIN_CODE_NOT_FOUND, QUESTION_NOT_ACTIVE, RESPONSE_LIMIT_REACHED, QUESTION_LIMIT_REACHED,
QUESTION_FULL and INTERNAL. The web app shows a translated message for each code, so the
message text is only for developers.
REST changes that affect what the audience sees, such as editing or deleting the active question, reordering, renaming the presentation or changing its theme, notify the live layer, which pushes the change to the connected clients.
Socket.IO protocol
Section titled “Socket.IO protocol”The events are typed in packages/shared/src/protocol.ts and used by both the server and the web
app. Payloads arrive as unknown and every handler parses them with a zod schema before use.
Client to server
Section titled “Client to server”Every event takes an acknowledgement callback that receives either { ok: true, ... } or
{ ok: false, error: { code } }.
| Event | Payload | Acknowledgement |
|---|---|---|
participant:join |
{ joinCode, participantId } |
{ presentationId, title, theme, state } |
response:submit |
{ questionId, response } |
{ remaining } |
presenter:join |
{ presentationId, adminToken } |
{ title, theme, activeQuestionId, participants, results } |
presenter:activate |
{ questionId }; null means no active question |
{} |
presenter:reset |
{ questionId } |
{} |
Server to client
Section titled “Server to client”| Event | Sent to | Payload |
|---|---|---|
state |
participants | { activeQuestion, remaining }: the active question in public form and how many answers this participant has left for it |
presenter:state |
presenters | { activeQuestionId } |
results |
presenters | { questionId, results, total }, at most once every 250 ms per question |
presence |
presenters | { participants }: distinct participant ids currently connected |
presentation |
everyone | { title, theme }, whenever the title or the theme changes |
presentation:ended |
everyone | The presentation was deleted |
activeQuestion contains the id, type, title, index and total (for “Question 2 of 5”) and the
config after the type’s toPublicConfig, so a type can hide parts of its config from phones.
A session, step by step
Section titled “A session, step by step”- The presenter screen connects and sends
presenter:joinwith the admin token. If nothing is active, it sendspresenter:activatefor the first question. - A phone connects and sends
participant:joinwith the join code and its participant id. The acknowledgement carries the current state, and presenters get apresenceupdate. - The phone sends
response:submit. The server checks that the question is active, that the payload passes the type’s response schema and that the participant has answers left. The count check and the insert run in one synchronous SQLite transaction, so two quick submits can’t both pass. - Presenters receive
results, throttled per question. - The presenter sends
presenter:activatefor the next question, and every phone receives a newstate.
After every reconnect, clients send their join event again and render the fresh state, so a dropped connection can’t leave a phone on an old question.
Rooms and presence
Section titled “Rooms and presence”Each presentation has two rooms, p:<id>:participants and p:<id>:presenters. Presence counts
distinct participant ids, not sockets, so a phone with two tabs counts once. Presence and the
throttling timers are kept in memory, which is the main reason Oyla runs as a single instance.
Scaling out would need the Socket.IO Redis adapter and shared presence; see the
roadmap.
Question types
Section titled “Question types”A question type is defined once in packages/shared/src/question-types/ and rendered by three
components in packages/web/src/question-types/<type>/. The server has no code for individual
types: it validates and aggregates through the shared registry. The
step-by-step guide explains the interfaces.
Web app
Section titled “Web app”| Route | Who | Page |
|---|---|---|
/ |
everyone | Join form, Create presentation, Your presentations |
/new |
presenter | Name, and the server password when required |
/edit/:id#key=<token> |
presenter | Editor |
/present/:id |
presenter | Presenter screen |
/join/:code |
participant | Waiting screen, answer form, thanks screen |
- The admin token travels in the URL fragment, which browsers never send to the server. The editor
saves it in
localStorage(“Your presentations”) and removes the fragment from the address bar. - Phones only download the home and join pages and the answer forms. The editor, the presenter screen and the results charts are loaded on demand.
- The UI strings are typed dictionaries in
packages/web/src/i18n/en.tsandtr.ts; a missing key is a type error. The language follows the browser and can be switched. localStorageaccess is wrapped in try/catch, so the app works when storage is blocked.
Themes and colour tokens
Section titled “Themes and colour tokens”On the pages of a presentation, the web app sets data-palette="<theme>" on <html>, using the
theme from the join acknowledgement and from later presentation events. That is how a theme
change reaches every phone at once. Other pages, such as the home page, use the default theme.
Light and dark mode follow prefers-color-scheme on each device.
packages/web/src/index.css defines the colour tokens for every theme, in light and dark. The
semantic tokens are --brand, --on-brand, --brand-soft, --stamp, --danger, --on-danger,
--danger-soft, --series, --cloud-1 to --cloud-3, --ink, --muted, --line, --paper,
--page and --focus. Components use them through Tailwind classes such as bg-brand,
text-series or border-line, never hard-coded colours, so every component works in every theme
and in dark mode.
Background jobs
Section titled “Background jobs”One job: the retention sweep in packages/server/src/retention.ts runs at startup and every hour
and deletes presentations whose last_activity_at is older than OYLA_RETENTION_DAYS.
Security
Section titled “Security”Admin tokens, rate limits, the Content Security Policy and the other measures are described on the security page.