Skip to content

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 app

The 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.

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

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_id is stored, so a restart resumes the presentation where it was.
  • theme is one of the keys of THEMES in packages/shared/src/themes.ts, which also exports the ThemeKey type and DEFAULT_THEME: emerald (the default), indigo, navy and blue. The column is TEXT 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/drizzle are applied at startup.

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.

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.

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 } {}
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.

  1. The presenter screen connects and sends presenter:join with the admin token. If nothing is active, it sends presenter:activate for the first question.
  2. A phone connects and sends participant:join with the join code and its participant id. The acknowledgement carries the current state, and presenters get a presence update.
  3. 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.
  4. Presenters receive results, throttled per question.
  5. The presenter sends presenter:activate for the next question, and every phone receives a new state.

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.

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.

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.

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.ts and tr.ts; a missing key is a type error. The language follows the browser and can be switched.
  • localStorage access is wrapped in try/catch, so the app works when storage is blocked.

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.

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.

Admin tokens, rate limits, the Content Security Policy and the other measures are described on the security page.