Real-time, community-driven messaging platform — a Slack/Discord-style workspace where channels, threads, reactions, and presence all update live over WebSockets. Built with a Spring Boot backend and a React + TypeScript frontend.
- App: https://ripplechat-app.vercel.app
- API docs (Swagger UI): https://ripplechat-backend.onrender.com/swagger-ui.html
Hosted on free tiers. The backend (Render) is kept awake by an external uptime monitor, and the frontend additionally pings it on page load, so the demo is usually ready immediately. Rarely — right after a redeploy or a missed monitor ping — the first request can still hit a cold start of a few minutes; the UI shows a "waking up" notice while it boots. On the landing page, click “Demo’yu Dene” for a one-click guided account — no signup needed.
Demo account (or log in manually):
| Username | Password |
|---|---|
demo |
demo1234 |
It lands on a pre-seeded workspace (#genel, #yazılım, #tasarım) with sample messages, reactions, a thread and a poll.
- Live messaging over WebSocket / STOMP — messages fan out instantly to every subscriber of a channel
- Distributed Pub/Sub (Redis) — horizontally scalable WebSockets; messages published on any node are reliably broadcasted to users connected to other nodes via Redis Pub/Sub
- Voice & Video Calls (WebRTC) — peer-to-peer secure WebRTC calls with screen sharing (swapped in via
replaceTrack, no renegotiation), local/remote stream rendering, mute/video toggles, and signaling via STOMP - Optimistic UI — instant UI updates for messages and channels before server confirmation, with seamless fallback/error handling
- Presence — see who's online at a glance
- Typing indicators — know when someone in the channel is composing a message
- Automatic reconnection — the client recovers transparently from dropped connections, with a visible connection banner
- Channels with membership management, plus a discover view to browse and join the public channels you're not in yet (instead of only joining by id)
- Direct messages — private 1:1 conversations, plus group DMs (multi-party), reusing the same real-time pipeline
- Threads — keep focused reply chains off the main timeline
- Edit & delete messages — delete for everyone (soft delete) or delete for me (hide from your own view). Edited messages carry a "(düzenlendi)" badge that opens the full edit history — every superseded version is snapshotted and timestamped
- Quote-reply to a specific message, forward messages to other chats, and pin important messages
- Saved messages — bookmark any message and revisit them in a dedicated saved-items list (per-user, jump straight back to the message)
- Rich Text Editor (TipTap) — WYSIWYG editor with live markdown rendering, bold/italic, code blocks, quotes, and bullet lists
- @mentions with autocomplete, in-message highlighting, and a per-channel mention badge
- AI channel summarization (Claude) — a "✨ Özetle" button digests a channel's recent messages into a short catch-up summary via the official Anthropic SDK (
claude-3-5-sonnet-20241022by default,AI_MODEL-overridable). Gracefully disabled whenANTHROPIC_API_KEYis unset, per-user rate-limited, and membership-checked. (True embeddings-based semantic search would need a separate embeddings provider — Anthropic has no embeddings endpoint — so it's out of scope here.) - Advanced Full-Text Search (Elasticsearch) — sub-millisecond search across millions of messages with exact matching, wildcard support, and complex querying. Gracefully degrades to PostgreSQL full-text when Elasticsearch is unavailable, so the app still boots and search keeps working
- Scheduled messages — queue a message to a channel for a future time; a background dispatcher delivers due messages through the normal pipeline.
/remindschedules one as a quick reminder - Infinite scroll — older history loads as you scroll up
- Persistent emoji reactions on any message, plus a full emoji picker
- Live flying emoji — reactions burst across the screen in real time
- GIFs — search and send GIFs from a picker (Giphy)
- Polls — create and vote on polls right inside a channel (
/poll) - Slash commands — an extensible command system (
/poll,/giphy,/shrug,/remind)
- Image, file, and voice-message attachments (recorded in-browser), stored on Cloudinary
- Automatic Storage Cleanup — media files hosted on Cloudinary are reliably and automatically purged when the owning message is deleted
- Per-channel media gallery of shared images
- Link previews — URLs unfurl into title/description/image cards (server-side, SSRF-guarded)
- Incoming webhooks — a channel moderator mints a token'd URL; external systems (CI, monitoring, …) POST
{"text":"..."}and it lands in the channel as a dedicated bot identity. Only the token's SHA-256 hash is stored (the URL is shown once), the ingest endpoint is rate-limited, and bot accounts are hidden from people-search
- Presence and last-seen timestamps · typing indicators
- Custom status — an emoji + short text (with optional auto-expiry) shown next to your name and in DM headers
- Do Not Disturb — pause web-push notifications for a chosen window (30 min / 1 h / 8 h)
- Activity center — a bell with an unread badge that aggregates the events that involve you (someone @mentioned you, replied to your message, or reacted to it) into one feed; new activity arrives live over your own authorized STOMP topic, and clicking an item jumps to the message
- Read receipts — delivery/read ticks in direct messages
- Web push notifications (VAPID) for messages while you're away — suppressed while you're in Do Not Disturb
- Mute channels and DMs · unread badges with a live count in the browser tab title
- Two-Factor Authentication (2FA) — TOTP-based multi-factor authentication via Google Authenticator/Authy using a secure Pre-Auth JWT handshake, with single-use recovery codes (shown once at enrollment, hash-only storage) that stand in for the authenticator at login and can be regenerated
- JWT authentication with stateless sessions and BCrypt-hashed passwords
- Password reset & email verification — token'd, single-use, expiring links delivered by email (only the SHA-256 hash is stored). The forgot-password endpoint is rate-limited and never reveals whether an address is registered; a reset ends every existing session. Email gracefully degrades to logging the link when no SMTP server is configured, so the flows work in development
- Refresh tokens with rotation, IP/User-Agent metadata tracking, and server-side revocation — short-lived access tokens are renewed transparently, and logout truly invalidates the session
- Session & Device Management — users can view active devices (with browser, OS, and IP address details) and perform remote session revocation (log out other devices)
- Role-based authorization per channel:
OWNER›MODERATOR›MEMBER, with server-side moderation checks - Private channels — live messages are restricted to members; subscriptions are authorized per channel so non-members can't eavesdrop
- User blocking — hide messages from blocked users
- GDPR self-service — download all of your own data as JSON, and erase your account: personal data is scrubbed and sign-in/session/notification artifacts are purged, while your past messages are retained under an anonymised "Deleted User" so other people's conversation history stays intact
- End-to-end encryption (E2EE) (V2) (opt-in) for 1-to-1 direct messages — implements Signal's Double Ratchet Protocol and X3DH (Extended Triple Diffie-Hellman) key agreement using Web Crypto API. Features Forward Secrecy (past keys are deleted) and Break-in Recovery (new DH ratchet steps heal the session), with automatic prekey generation and replenishment. Also supports legacy manual passphrase-derived (PBKDF2) symmetric encryption. Plaintexts are cached locally in the browser's IndexedDB decrypted cache, keeping the server blind to the content.
- Abuse protection — input size limits plus distributed (Redis) rate limiting on login, 2FA verification, registration, message sends, reactions, and webhook ingestion (rate-limited responses carry a
Retry-Afterhint) - Account lockout — after repeated failed password attempts an account is temporarily locked (auto-unlocks after a short cooldown, kept short to bound the DoS surface of a targeted lockout; the demo account is exempt). Counters live in Redis and lock events are audit-logged
- Security headers — the backend sets HSTS,
X-Frame-Options: DENY,X-Content-Type-Options: nosniff, aReferrer-Policy, and aframe-ancestorsCSP on every response; the static frontend (Vercel) adds a strict enforced Content-Security-Policy,Permissions-Policy, and the same hardening headers - Hardened secrets — the JWT secret is validated at startup (rejects a too-short or placeholder value); SSRF guard on link unfurling; outbound calls (Cloudinary, link/GIF) are timeout-bounded
- Security audit log — authentication events (login success/failure/throttle, registration, refresh, logout) on a dedicated logger, correlated by request id
- Admin panel — global platform administrators (bootstrapped from
ADMIN_USERNAMES— re-applied on every boot, so those users are effectively permanent admins — with further grants self-managed in-app) get a dedicated panel: headline stats (users, admins, channels, messages), a user table to grant/revoke admin and disable/ban accounts (a disabled user can't sign in; admins can't lock themselves out), and a persisted audit trail of every admin action. Access is gated server-side on the user's admin flag
- Quick switcher —
Ctrl/Cmd+Kopens a Slack-style palette to jump between channels and DMs with full keyboard navigation - Light / dark theme toggle and a responsive layout that adapts to mobile
- Offline-First PWA — progressive web app with a service worker (
vite-plugin-pwa) that caches UI assets and uses IndexedDB (idb) to store messages locally. Users can read history and send "pending" messages while offline, which automatically sync to the backend when the network recovers. - Internationalization — English / Turkish with a language toggle
- Accessibility — accessible dialogs (focus trap, Escape, focus restore), ARIA labels, a skip link, and automated axe checks in CI
- Resilient UI — an error boundary keeps a component crash from blanking the app, and a toast layer surfaces transient successes/errors
- Fast first load — route-level code splitting and on-demand chunks (pickers, syntax highlighter), plus gzip-compressed API responses
- Disappearing messages — an optional per-channel timer auto-deletes messages after it elapses
- Channel organization — categories and archiving · profile & settings (display name, avatar image/color, password)
- Tested — backend integration tests on real PostgreSQL (Testcontainers) incl. a STOMP realtime test, frontend unit tests (Vitest), end-to-end + accessibility tests (Playwright), architecture tests (ArchUnit) and mutation testing (PITest) — all wired into CI
- RFC 7807 problem responses — every error (validation, auth 401/403, framework) returns a consistent
application/problem+jsonbody - Prometheus metrics at
/actuator/prometheusand build info (version, build time) at/actuator/info - Request correlation — each request gets an
X-Request-Idthat is echoed back and stamped on every log line for that request - Distributed tracing — Micrometer Tracing over OpenTelemetry stamps a
traceId/spanIdon every log line and can export spans to an OTLP collector (Grafana Tempo / Jaeger). Sampling is off by default (no collector needed to run); setTRACING_SAMPLE_RATE+OTLP_ENDPOINTto enable export - OpenAPI / Swagger UI (
/swagger-ui.html, with a Bearer "Authorize") · health endpoint (/actuator/health)
Benchmarked locally with k6 (20 virtual users, 30 s sustained load) against the read-heavy API path:
| Endpoint | Method | p50 | p95 | Threshold |
|---|---|---|---|---|
/api/channels |
GET | ~7 ms | ~11 ms | < 800 ms |
/api/channels/{id}/messages?page=0&size=20 |
GET | ~12 ms | ~18 ms | < 800 ms |
/api/search/messages?q=… |
GET | ~9 ms | ~14 ms | < 800 ms |
| Error rate | 0 % measured (< 1 % threshold) |
Latencies are from a local run on Spring Boot 4.1 (
k6 run loadtest/messaging.js) with PostgreSQL in Docker and no Elasticsearch (search exercises the PostgreSQL full-text fallback). The hosted demo runs on free tiers (Render + Neon); an uptime monitor keeps the backend awake, but on the rare cold start (after a redeploy or missed ping) the JVM takes a few minutes to boot on the free instance. Warmed-up response times are comparable to the local numbers.
To reproduce:
k6 run loadtest/messaging.js # defaults: 20 VUs, 30 s
k6 run -e BASE_URL=http://localhost:8081 -e VUS=50 -e DURATION=1m loadtest/messaging.jsBackend
- Java 21
- Spring Boot 3.5
- Spring Security (JWT access + rotating refresh tokens, HS384)
- Spring Data JPA · Spring Data Redis · Spring Data Elasticsearch
- Spring WebSocket (STOMP messaging)
- PostgreSQL (primary datastore) · Redis (distributed Pub/Sub & caching) · Elasticsearch (message search engine)
- Cloudinary (media uploads) · web-push/VAPID (notifications) · dev.samstevens.totp (2FA) · Giphy (GIF search) · Anthropic Java SDK (Claude channel summarization)
- Caffeine (link-preview cache) · RFC 7807 ProblemDetail · gzip compression
- springdoc-openapi (Swagger UI) · Spring Boot Actuator · Micrometer + Prometheus
Frontend
- React + TypeScript
- Vite
- Redux Toolkit
- Tailwind CSS
- STOMP.js / SockJS
- TipTap (Rich Text Editor)
- WebRTC (P2P Video/Audio Calls + screen sharing)
- Web Crypto (E2EE) · Service Worker (PWA + push) · IndexedDB (Offline sync via idb)
- TypeScript strict mode · error boundary + in-house toast layer · route-level code splitting
Testing
- JUnit 5 + Testcontainers (real PostgreSQL) — backend integration tests · JaCoCo code coverage (
mvn verify→target/site/jacoco/) - Vitest + React Testing Library — frontend unit tests · v8 coverage (
npm run test:coverage→coverage/) - Playwright — end-to-end tests
- ArchUnit — architecture/boundary tests · PITest — mutation testing (
mvn -Ppitest test org.pitest:pitest-maven:mutationCoverage) - axe (
@axe-core/playwright) — automated accessibility checks - k6 — load test for the read-heavy API path (
k6 run loadtest/messaging.jsagainst a local instance)
DevOps
- Docker Compose (PostgreSQL + optional STOMP-enabled RabbitMQ)
- Flyway (production schema migrations)
- Maven · Spring profiles (dev / prod)
- GitHub Actions CI (backend, frontend, e2e) · container image build & publish to GHCR on push to
main(with an optional deploy-hook step) · Dependabot (Maven, npm, Actions) ·npm auditgate on shipped dependencies
The system at a glance — a React PWA talking to a horizontally-scalable Spring Boot monolith over REST + STOMP, backed by PostgreSQL / Redis / Elasticsearch, with all third-party services optional and graceful-disabled:
flowchart TB
subgraph Client["🌐 Client — React 19 + TypeScript PWA"]
direction LR
UI["Redux Toolkit UI<br/>route-split pages"]
RT["Realtime layer<br/>STOMP / SockJS"]
SEC["Web Crypto E2EE ·<br/>Service Worker · IndexedDB"]
end
subgraph Backend["☕ Spring Boot 3.5 — modular monolith · multiple replicas"]
direction TB
JWT["JWT auth filter +<br/>per-request rate limit"]
REST["REST controllers<br/>/api/**"]
WS["STOMP WebSocket<br/>/ws · /topic · /app"]
SVC["Domain services<br/>auth · channel · message · search · admin · ai · …"]
JWT --> REST --> SVC
JWT --> WS --> SVC
end
subgraph Data["🗄️ Stateful infrastructure"]
direction LR
PG[("PostgreSQL 16<br/>Flyway V1–V37")]
RD[("Redis<br/>pub/sub · rate limit<br/>lockout · ShedLock")]
ES[("Elasticsearch<br/>search + PG fallback")]
end
subgraph Ext["🔌 External services — optional, graceful-disable"]
direction LR
CL["Cloudinary"]
AN["Anthropic Claude"]
GF["Giphy"]
SM["SMTP"]
VP["Web Push / VAPID"]
end
UI -->|HTTPS REST| REST
RT <-->|WSS STOMP| WS
SVC --> PG
SVC --> ES
SVC --> RD
WS <-->|cross-replica fan-out| RD
SVC -.-> CL & AN & GF & SM & VP
Modular monolith backend. The codebase is organized by domain, each package owning its own controllers, services, and persistence:
auth · user · channel (+ membership · direct messages · categories) · message (+ threads · pins · forwards · scheduled · edit history)
presence · typing · reaction · poll · search (full-text) · read receipts · push · notification · bookmark · link previews · media · gif
webhook · mail · scheduling (ShedLock) · ai (Claude summarization) · admin (moderation + audit log) · websocket
e2ee (X3DH prekeys · group sender keys) · outbox (transactional async cleanup) · redis (rate limiting · pub/sub fan-out) · demo (seeded demo workspace)
WebSocket layer. Clients open a single STOMP connection authenticated with a JWT on CONNECT. The server broadcasts to /topic/... destinations (e.g. /topic/channels/{id}); clients publish via /app/.... Messages, reactions, presence, typing, and polls all travel over this channel for instant fan-out.
Frontend. Application state lives in Redux Toolkit, with a dedicated realtime layer managing the socket lifecycle, subscriptions, and reconnection. UI state (theme, modals, unread counts) is kept in feature slices.
Security is enforced on the backend. Authentication and every role/permission check (channel membership, moderation, profile ownership) run server-side — the frontend never holds the authority, only reflects it.
Dev vs. production. Development favours fast iteration: Hibernate auto-updates the schema (ddl-auto=update) and SQL logging is on. The prod profile is hardened instead — schema is owned by Flyway migrations and only validated by Hibernate, SQL logging is off, and CORS / WebSocket origins come from an explicit, environment-configured allowlist (no wildcards). Responses also carry hardening headers (HSTS, Referrer-Policy, frame-deny, a frame-ancestors CSP).
Note on the Swagger UI. It's left publicly reachable on this demo on purpose, as a showcase of the API surface — the exposure is low since every mutating endpoint requires a JWT and no secrets are returned. In a real production deployment you'd gate it behind authentication or disable it (
springdoc.api-docs.enabled=false) to avoid publishing the API map.
- Java 21
- Node.js (with npm)
- Docker (for PostgreSQL)
- Maven (or use the bundled
mvnwwrapper)
git clone <your-repo-url> ripplechat
cd ripplechat
cp .env.example .envEdit .env and set real values — most importantly a strong JWT_SECRET (≥ 32 bytes; generate one with openssl rand -hex 48) and your PostgreSQL credentials.
The Vite dev server proxies API and WebSocket traffic to the backend on port 8081, so set
SERVER_PORT=8081in your.envfor local development.
docker compose up -dcd backend
./mvnw spring-boot:runThe API starts on http://localhost:8081.
cd frontend
npm install
cp .env.example .env
npm run devOpen http://localhost:5173 — register an account and start chatting. The dev server forwards /api and /ws to the backend, so the browser stays same-origin (no CORS setup needed).
| Service | URL / Port |
|---|---|
| Frontend | http://localhost:5173 |
| Backend API | http://localhost:8081 |
| PostgreSQL | localhost:5434 (host) |
The prod profile swaps auto-schema for validated, Flyway-managed migrations and locks down origins. Activate it with SPRING_PROFILES_ACTIVE=prod and provide:
| Variable | Notes |
|---|---|
SPRING_PROFILES_ACTIVE=prod |
enables Flyway + ddl-auto=validate, disables SQL logging |
POSTGRES_PASSWORD |
strong, unique (never change-me) |
JWT_SECRET |
long random secret — openssl rand -hex 48 |
APP_ALLOWED_ORIGINS |
comma-separated allowed origins, e.g. https://chat.example.com |
POSTGRES_DB · POSTGRES_USER · POSTGRES_HOST_PORT · SERVER_PORT |
point at the production database / port |
On first boot against an empty database, Flyway applies the migrations in order (V1__initial_schema … V37__group_sender_keys) and Hibernate validates the schema against the entities. The full environment list lives in .env.example.
Several features are optional and gracefully disabled when their credentials are absent, so the app always boots: CLOUDINARY_URL (image/file/voice uploads), VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT (web push), GIPHY_API_KEY (GIF search), and SMTP (MAIL_ENABLED + MAIL_HOST/MAIL_USERNAME/MAIL_PASSWORD) for password-reset / verification email — without it those links are logged to the console instead of sent. Set APP_SEARCH_ELASTICSEARCH_ENABLED=false to run without Elasticsearch (search falls back to PostgreSQL full-text and the app never contacts ES), and SWAGGER_ENABLED=false to stop publishing the API docs. End-to-end encryption is entirely client-side and needs no server configuration.
ripplechat/
├── backend/ # Spring Boot application
│ ├── src/main/java/com/ripplechat/backend/
│ │ ├── auth/ # Registration, login, JWT, security config
│ │ ├── user/ # Profiles, status/DND, settings, password, avatars, blocking
│ │ ├── channel/ # Channels, membership & roles, direct/group messages
│ │ ├── admin/ # Platform admin panel: user moderation + audit log
│ │ ├── demo/ # One-click demo account + seeded demo workspace
│ │ ├── message/ # Messages, threads, edit history, pins, forwards, quotes
│ │ │ └── scheduled/ # Scheduled messages + background dispatcher
│ │ ├── ai/ # Claude channel summarization (Anthropic SDK, graceful-disable)
│ │ ├── webhook/ # Incoming webhooks (token'd ingest, bot identity)
│ │ ├── reaction/ # Emoji reactions
│ │ ├── poll/ # Polls (REST + WebSocket, persisted)
│ │ ├── presence/ # Online status & last seen
│ │ ├── typing/ # Typing indicators
│ │ ├── read/ # Read receipts
│ │ ├── notification/ # Activity center (mentions, replies, reactions)
│ │ ├── bookmark/ # Saved / bookmarked messages
│ │ ├── search/ # Message search (Elasticsearch, PostgreSQL tsvector fallback)
│ │ ├── e2ee/ # Server side of E2EE: X3DH prekey storage, group sender keys
│ │ ├── push/ # Web push (VAPID) subscriptions & sending
│ │ ├── mail/ # Transactional email (reset / verification; logs when no SMTP)
│ │ ├── scheduling/ # ShedLock single-runner locking for @Scheduled tasks
│ │ ├── outbox/ # Transactional outbox for reliable async work (media cleanup)
│ │ ├── link/ # Link-preview unfurling (jsoup, SSRF-guarded)
│ │ ├── media/ · gif/ # Cloudinary uploads · Giphy GIF search
│ │ ├── redis/ # Redis rate limiter + cross-replica STOMP pub/sub bridge
│ │ ├── websocket/ # STOMP config & subscription auth
│ │ └── common/ # Shared errors, exceptions, request-id filter
│ └── src/main/resources/
│ └── db/migration/ # Flyway migrations V1–V37 (prod schema)
├── frontend/ # React + TypeScript app
│ ├── public/ # PWA manifest + service worker (sw.js)
│ └── src/
│ ├── api/ # HTTP client & types
│ ├── app/ # Redux store & hooks
│ ├── features/ # auth, channels, messages, threads, polls, presence,
│ │ # reads, blocks, muted, e2ee, unread, connection, ui …
│ ├── realtime/ # STOMP socket lifecycle
│ ├── crypto/ # Client-side E2EE (Web Crypto)
│ ├── i18n/ # EN/TR translations + provider
│ ├── commands/ # Slash-command registry
│ ├── components/ # UI components (+ ui/ primitives & useDialog)
│ ├── pages/ # Route-level views
│ └── ../e2e/ # Playwright end-to-end tests
├── docker-compose.yml # PostgreSQL (+ STOMP-enabled RabbitMQ for the opt-in relay)
└── .env.example # Environment template
Channel — live messages, reactions, a thread, markdown and a syntax-highlighted code block:
Dark mode — the same channel with the dark theme:
Search — full-text search across chats with highlighted matches and channel/sender/date filters:
Direct message — a private 1:1 conversation:
End-to-end encryption — the 🔒 badge in the DM header confirms E2EE is active, next to delivery/read ticks:
Admin panel — platform stats, user management, and the audit log:
Mobile — responsive layout on a phone viewport:
RippleChat is built to run behind a load balancer as multiple backend replicas. The pieces that would otherwise be per-instance state have been moved to shared infrastructure:
- Distributed rate limiting — the limiter is backed by Redis via an atomic token-bucket Lua script, so limits hold across replicas instead of per-instance.
- Cross-replica WebSocket fan-out — the local STOMP
SimpleBrokeris fronted by a Redis Pub/Sub bridge: a message published on one replica is fanned out to subscribers connected to any replica. (Each node still serves its own clients viaSimpMessagingTemplate; Redis carries the cross-node hop.)
The timer-driven @Scheduled tasks are also replica-safe:
- Single-runner scheduling (ShedLock) — the disappearing-message expiry sweep and the scheduled-message dispatcher run on a timer. On multiple replicas they would run redundantly (e.g. double-delivering a scheduled message), so each task is wrapped with
@SchedulerLockand a ShedLock distributed lock (backed by Postgres) elects a single runner — exactly one replica executes each tick.
An external STOMP relay is also implemented, as an opt-in alternative to the Redis Pub/Sub bridge: set
WEBSOCKET_BROKER_TYPE=rabbitmq(plus theRABBITMQ_*connection variables — a STOMP-enabled RabbitMQ ships indocker-compose.yml) and the in-JVM broker is replaced withenableStompBrokerRelay, moving broker state out of the application entirely. The default stays the lighter Redis Pub/Sub bridge, which avoids the extra broker dependency.
Larger features still on the roadmap: group voice/video calls via an SFU media server (the current WebRTC calls are peer-to-peer 1:1 — see the grounded architecture plan in docs/group-calls-sfu.md), and a native mobile wrapper (Capacitor) around the existing PWA (wiring plan in docs/mobile-capacitor.md).
This project is licensed under the MIT License.








