9 Commits

18 changed files with 802 additions and 142 deletions

View File

@@ -1,11 +1,37 @@
import { copyFile, mkdir } from 'node:fs/promises';
import { resolve } from 'node:path';
import { dirname, resolve } from 'node:path';
const root = process.cwd();
const outputDirectory = resolve(root, 'dist');
const serverDirectory = resolve(root, 'dist', 'server');
const appShell = resolve(outputDirectory, 'index.html');
const browserRoutes = [
'encryption',
'privacy',
'proto',
'proto/docs',
'rcc',
'support',
'terms',
];
await mkdir(serverDirectory, { recursive: true });
await copyFile(
resolve(root, 'worker', 'index.js'),
resolve(serverDirectory, 'index.js'),
);
// Emit both clean-URL and directory-index shells for every client-side route.
// The directory copy also supports basic Nginx static configurations that do
// not rewrite missing paths to the root application shell.
await Promise.all(
browserRoutes.map(async (route) => {
const routeFile = resolve(outputDirectory, `${route}.html`);
const routeDirectory = resolve(outputDirectory, route);
await mkdir(dirname(routeFile), { recursive: true });
await mkdir(routeDirectory, { recursive: true });
await copyFile(appShell, routeFile);
await copyFile(appShell, resolve(routeDirectory, 'index.html'));
}),
);

19
nginx.conf Normal file
View File

@@ -0,0 +1,19 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /assets/ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
add_header Cache-Control "no-cache" always;
try_files $uri $uri/ /index.html;
}
}

View File

@@ -2,57 +2,56 @@ version: 3
description: Add avatars and more CDN safety, remove transport server
enums:
#Тип вложния
#тип вложния
AttachmentType:
type: int8
values:
- id: 0
name: IMAGE #Изображение
name: IMAGE #изображение
- id: 1
name: MESSAGES #Сообщения (forwarded/reply)
name: MESSAGES #сообщения (forwarded/reply)
- id: 2
name: FILE #Файл
name: FILE #файл
- id: 4
name: CALL #Звонок
name: CALL #звонок
- id: 5
name: VOICE #Голосовое сообщение
name: VOICE #голосовое сообщение
- id: 6
name: VIDEO_CIRCLE #Видео сообщение (кружок)
name: VIDEO_CIRCLE #видео сообщение (кружок)
- id: 7
name: VIDEO #Видео
name: VIDEO #видео
#Решение по девайсу, принять или отклонить вход
#решение по девайсу, принять или отклонить вход
DeviceSolution:
type: int8
values:
- id: 0
name: ACCEPT #Принять
name: ACCEPT #принять
- id: 1
name: DECLINE #Отклонить
name: DECLINE #отклонить
#Статус хэндшейка
#статус хэндшейка
HandshakeStage:
type: int8
values:
- id: 0
name: COMPLETED #Пройден
name: COMPLETED #пройден
- id: 1
name: NEED_DEVICE_VERIFICATION #Не пройден (приложение рендерит UI подтвердите с другого устройства)
name: NEED_DEVICE_VERIFICATION #не пройден (приложение рендерит UI подтвердите с другого устройства)
#Статус в группе
#статус в группе
GroupStatus:
type: int8
values:
- id: 0
name: JOINED #В группе
name: JOINED #в группе
- id: 1
name: INVALID #Не валидная группа
name: INVALID #не валидная группа
- id: 2
name: NOT_JOINED #Можно зайт
name: NOT_JOINED #можно зайт
- id: 3
name: BANNED #Заблокирован
name: BANNED #заблокирован
#
NotificationAction:
type: int8
values:

View File

@@ -8,10 +8,16 @@
</url>
<url>
<loc>https://rosetta.im/proto/</loc>
<lastmod>2026-07-30</lastmod>
<lastmod>2026-08-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://rosetta.im/rcc/</loc>
<lastmod>2026-08-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://rosetta.im/proto/docs/</loc>
<lastmod>2026-07-31</lastmod>

View File

@@ -18,14 +18,20 @@ async function exists(path) {
}
}
function capitalizeFirstLetter(value) {
return value.trim().replace(/\p{L}/u, (letter) => letter.toLocaleUpperCase());
}
function cleanComment(...comments) {
return comments
const description = comments
.filter(Boolean)
.join('\n')
.split('\n')
.map((line) => line.replace(/^\s*#?\s?/, '').trim())
.filter(Boolean)
.join(' ');
return capitalizeFirstLetter(description);
}
function mapFields(node) {
@@ -128,7 +134,7 @@ async function readLayer(path) {
return {
version: Number(document.get('version')),
description: String(document.get('description') || ''),
description: capitalizeFirstLetter(String(document.get('description') || '')),
source: basename(path),
enums: mapNamedCollection(document.get('enums', true), 'enum'),
types: mapNamedCollection(document.get('types', true), 'type'),

View File

@@ -1,7 +1,7 @@
import '@mantine/core/styles.css';
import { Center, createTheme, Loader, MantineProvider } from '@mantine/core';
import { lazy, Suspense } from 'react';
import { Button, Center, createTheme, Loader, MantineProvider, Stack, Text, Title } from '@mantine/core';
import { Component, lazy, Suspense, type ReactNode } from 'react';
import { Route, Routes } from 'react-router-dom';
import { ScrollToTop } from './components/ScrollToTop/ScrollToTop';
import './style.css';
@@ -18,6 +18,9 @@ const ProtoPage = lazy(() =>
const ProtoDocsPage = lazy(() =>
import('./pages/ProtoPage').then((module) => ({ default: module.ProtoDocsPage })),
);
const RccPage = lazy(() =>
import('./pages/RccPage').then((module) => ({ default: module.RccPage })),
);
const SupportPage = lazy(() =>
import('./pages/SupportPage').then((module) => ({ default: module.SupportPage })),
);
@@ -39,27 +42,61 @@ const theme = createTheme({
},
});
class PageErrorBoundary extends Component<
{ children: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<Center mih="100vh" p="xl">
<Stack align="center" gap="md" maw={460}>
<Title order={2} ta="center">
This page could not be loaded
</Title>
<Text c="dimmed" ta="center">
Rosetta may have been updated while this tab was open. Reload the page to continue.
</Text>
<Button onClick={() => window.location.reload()}>Reload page</Button>
</Stack>
</Center>
);
}
return this.props.children;
}
}
export default function App() {
return (
<MantineProvider theme={theme}>
<ScrollToTop />
<Suspense
fallback={
<Center mih="100vh">
<Loader aria-label="Loading page" />
</Center>
}
>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/encryption" element={<EncryptionPage />} />
<Route path="/proto" element={<ProtoPage />} />
<Route path="/proto/docs" element={<ProtoDocsPage />} />
<Route path="/support" element={<SupportPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/terms" element={<TermsPage />} />
</Routes>
</Suspense>
<PageErrorBoundary>
<ScrollToTop />
<Suspense
fallback={
<Center mih="100vh">
<Loader aria-label="Loading page" />
</Center>
}
>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/encryption" element={<EncryptionPage />} />
<Route path="/proto" element={<ProtoPage />} />
<Route path="/proto/docs" element={<ProtoDocsPage />} />
<Route path="/rcc" element={<RccPage />} />
<Route path="/support" element={<SupportPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/terms" element={<TermsPage />} />
</Routes>
</Suspense>
</PageErrorBoundary>
</MantineProvider>
);
}

View File

@@ -1,18 +1,22 @@
.page {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 100vh;
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-9));
}
.shell {
flex: 1;
width: 100%;
min-width: 0;
padding-top: 72px;
padding-bottom: 96px;
}
.main {
width: 100%;
min-width: 0;
max-width: 900px;
margin-inline: auto;
padding: clamp(30px, 5vw, 60px);
@@ -21,6 +25,7 @@
}
.intro {
min-width: 0;
max-width: 760px;
padding-bottom: 36px;
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
@@ -69,6 +74,7 @@
}
.section {
min-width: 0;
margin-top: 44px;
padding-top: 36px;
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));

View File

@@ -5,15 +5,42 @@ export function ScrollToTop() {
const { pathname, hash } = useLocation();
useEffect(() => {
const frame = window.requestAnimationFrame(() => {
if (hash) {
const target = document.getElementById(decodeURIComponent(hash.slice(1)));
if (target) {
target.scrollIntoView();
return;
}
if (hash) {
let targetId: string;
try {
targetId = decodeURIComponent(hash.slice(1));
} catch {
targetId = hash.slice(1);
}
const scrollToTarget = () => {
const target = document.getElementById(targetId);
if (!target) {
return false;
}
target.scrollIntoView({ block: 'start' });
return true;
};
if (scrollToTarget()) {
return;
}
const observer = new MutationObserver(() => {
if (scrollToTarget()) {
observer.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}
const frame = window.requestAnimationFrame(() => {
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
});

View File

@@ -9,7 +9,7 @@ export function SiteFooter() {
return (
<footer className={classes.footer}>
<Container>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xl">
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="xl">
<Stack gap="sm">
<Group gap="sm">
<RosettaLogo size={34} />
@@ -33,14 +33,23 @@ export function SiteFooter() {
<Anchor component={Link} to="/#apps" c="dimmed" size="sm">
Apps
</Anchor>
<Anchor component={Link} to="/encryption" c="dimmed" size="sm">
Encryption
<Anchor component={Link} to="/support" c="dimmed" size="sm">
Support
</Anchor>
</Stack>
<Stack gap="xs">
<Text fw={600} size="sm">
Developers
</Text>
<Anchor component={Link} to="/proto" c="dimmed" size="sm">
Proto
</Anchor>
<Anchor component={Link} to="/support" c="dimmed" size="sm">
Support
<Anchor component={Link} to="/encryption" c="dimmed" size="sm">
Encryption
</Anchor>
<Anchor component={Link} to="/rcc" c="dimmed" size="sm">
RCC
</Anchor>
</Stack>

View File

@@ -2402,101 +2402,101 @@ export const protoLayers = [
{
"id": 0,
"name": "IMAGE",
"description": ""
"description": "Изображение"
},
{
"id": 1,
"name": "MESSAGES",
"description": ""
"description": "Сообщения (forwarded/reply)"
},
{
"id": 2,
"name": "FILE",
"description": ""
"description": "Файл"
},
{
"id": 4,
"name": "CALL",
"description": ""
"description": "Звонок"
},
{
"id": 5,
"name": "VOICE",
"description": ""
"description": "Голосовое сообщение"
},
{
"id": 6,
"name": "VIDEO_CIRCLE",
"description": ""
"description": "Видео сообщение (кружок)"
},
{
"id": 7,
"name": "VIDEO",
"description": ""
"description": "Видео"
}
]
},
{
"name": "DeviceSolution",
"type": "int8",
"description": "",
"description": "Решение по девайсу, принять или отклонить вход",
"yaml": "DeviceSolution:\n type: int8\n values:\n - id: 0\n name: ACCEPT\n - id: 1\n name: DECLINE",
"values": [
{
"id": 0,
"name": "ACCEPT",
"description": ""
"description": "Принять"
},
{
"id": 1,
"name": "DECLINE",
"description": ""
"description": "Отклонить"
}
]
},
{
"name": "HandshakeStage",
"type": "int8",
"description": "",
"description": "Статус хэндшейка",
"yaml": "HandshakeStage:\n type: int8\n values:\n - id: 0\n name: COMPLETED\n - id: 1\n name: NEED_DEVICE_VERIFICATION",
"values": [
{
"id": 0,
"name": "COMPLETED",
"description": ""
"description": "Пройден"
},
{
"id": 1,
"name": "NEED_DEVICE_VERIFICATION",
"description": ""
"description": "Не пройден (приложение рендерит UI подтвердите с другого устройства)"
}
]
},
{
"name": "GroupStatus",
"type": "int8",
"description": "",
"description": "Статус в группе",
"yaml": "GroupStatus:\n type: int8\n values:\n - id: 0\n name: JOINED\n - id: 1\n name: INVALID\n - id: 2\n name: NOT_JOINED\n - id: 3\n name: BANNED",
"values": [
{
"id": 0,
"name": "JOINED",
"description": ""
"description": "В группе"
},
{
"id": 1,
"name": "INVALID",
"description": ""
"description": "Не валидная группа"
},
{
"id": 2,
"name": "NOT_JOINED",
"description": ""
"description": "Можно зайт"
},
{
"id": 3,
"name": "BANNED",
"description": ""
"description": "Заблокирован"
}
]
},
@@ -2615,23 +2615,23 @@ export const protoLayers = [
{
"name": "WebRTCType",
"type": "int8",
"description": "",
"description": "Тип WebRTC сигнала",
"yaml": "WebRTCType:\n type: int8\n values:\n - id: 0\n name: OFFER\n - id: 1\n name: ANSWER\n - id: 2\n name: ICE_CANDIDATE",
"values": [
{
"id": 0,
"name": "OFFER",
"description": ""
"description": "Offer"
},
{
"id": 1,
"name": "ANSWER",
"description": ""
"description": "Answer"
},
{
"id": 2,
"name": "ICE_CANDIDATE",
"description": ""
"description": "Estabilishment candidate"
}
]
},
@@ -2654,22 +2654,22 @@ export const protoLayers = [
{
"id": 2,
"name": "INVALID",
"description": "любая ошибка смысл которой зависит от контекста, например в случае с файловой системой - нет файла"
"description": "Любая ошибка смысл которой зависит от контекста, например в случае с файловой системой - нет файла"
},
{
"id": 3,
"name": "USERNAME_TAKEN",
"description": ""
"description": "Имя пользователя занято"
},
{
"id": 4,
"name": "OVERSIZE",
"description": "слишком большой размер файла"
"description": "Слишком большой размер файла"
},
{
"id": 5,
"name": "OVERLIFE",
"description": "слишком большой TTL"
"description": "Слишком большой TTL"
}
]
},
@@ -2716,35 +2716,35 @@ export const protoLayers = [
"name": "id",
"type": "string",
"optional": false,
"description": ""
"description": "Случайная строка"
},
{
"id": 2,
"name": "blob",
"type": "string",
"optional": true,
"description": ""
"description": "Только очень коротких типов"
},
{
"id": 3,
"name": "type",
"type": "AttachmentType",
"optional": false,
"description": ""
"description": "Тип вложения"
},
{
"id": 4,
"name": "preview",
"type": "string",
"optional": false,
"description": ""
"description": "Превью"
},
{
"id": 6,
"name": "objectId",
"type": "string",
"optional": false,
"description": ""
"description": "Выданный сервером objectId от cdn"
}
]
},
@@ -2758,35 +2758,35 @@ export const protoLayers = [
"name": "preview",
"type": "string",
"optional": false,
"description": "превью (для фото blurhash)"
"description": "Превью (для фото blurhash)"
},
{
"id": 2,
"name": "objectId",
"type": "string",
"optional": false,
"description": "обьект на cdn"
"description": "Обьект на cdn"
},
{
"id": 3,
"name": "type",
"type": "ProfileMediaType",
"optional": false,
"description": "тип медиа"
"description": "Тип медиа"
},
{
"id": 4,
"name": "timestamp",
"type": "int64",
"optional": false,
"description": "дата публикации"
"description": "Дата публикации"
},
{
"id": 5,
"name": "mediaKey",
"type": "string",
"optional": false,
"description": "ключ шифрования от медиа"
"description": "Ключ шифрования от медиа"
}
]
},
@@ -2863,42 +2863,42 @@ export const protoLayers = [
"name": "username",
"type": "string",
"optional": false,
"description": "имя для поиска"
"description": "Имя для поиска"
},
{
"id": 2,
"name": "title",
"type": "string",
"optional": false,
"description": "отображаемое имя пользователя"
"description": "Отображаемое имя пользователя"
},
{
"id": 3,
"name": "publicKey",
"type": "string",
"optional": false,
"description": "публичный ключ"
"description": "Публичный ключ"
},
{
"id": 4,
"name": "verified",
"type": "int32",
"optional": false,
"description": "верификация"
"description": "Верификация"
},
{
"id": 5,
"name": "networkStatus",
"type": "NetworkStatus",
"optional": false,
"description": "онлайн пользователь или нет"
"description": "Онлайн пользователь или нет"
},
{
"id": 6,
"name": "avatar",
"type": "ProfileMedia",
"optional": true,
"description": "аватар пользователя если есть"
"description": "Аватар пользователя если есть"
}
]
},
@@ -2912,42 +2912,42 @@ export const protoLayers = [
"name": "username",
"type": "string",
"optional": false,
"description": "имя для поиска"
"description": "Имя для поиска"
},
{
"id": 2,
"name": "title",
"type": "string",
"optional": false,
"description": "отображаемое имя пользователя"
"description": "Отображаемое имя пользователя"
},
{
"id": 3,
"name": "publicKey",
"type": "string",
"optional": false,
"description": "публичный ключ"
"description": "Публичный ключ"
},
{
"id": 4,
"name": "verified",
"type": "int32",
"optional": false,
"description": "верификация"
"description": "Верификация"
},
{
"id": 5,
"name": "networkStatus",
"type": "NetworkStatus",
"optional": false,
"description": "онлайн пользователь или нет"
"description": "Онлайн пользователь или нет"
},
{
"id": 6,
"name": "media",
"type": "ProfileMedia[]",
"optional": true,
"description": "все активное медиа (включая будущие истории)"
"description": "Все активное медиа (включая будущие истории)"
}
]
},
@@ -3361,7 +3361,7 @@ export const protoLayers = [
"name": "server",
"type": "string",
"optional": true,
"description": "выбранный транспортный сервер"
"description": "Выбранный транспортный сервер"
}
]
},
@@ -3690,42 +3690,42 @@ export const protoLayers = [
"name": "size",
"type": "uint32",
"optional": false,
"description": "размер загружаемого файла (декларируемый)"
"description": "Размер загружаемого файла (декларируемый)"
},
{
"id": 2,
"name": "objectId",
"type": "string",
"optional": true,
"description": "выделенный сервером objectId"
"description": "Выделенный сервером objectId"
},
{
"id": 3,
"name": "chunkLength",
"type": "uint32",
"optional": true,
"description": "размер чанка"
"description": "Размер чанка"
},
{
"id": 4,
"name": "chunks",
"type": "uint32",
"optional": true,
"description": "количество чанков"
"description": "Количество чанков"
},
{
"id": 5,
"name": "host",
"type": "string",
"optional": true,
"description": "хост, где сохранен файл"
"description": "Хост, где сохранен файл"
},
{
"id": 6,
"name": "ttl",
"type": "uint32",
"optional": true,
"description": "ttl в секундах, после которого файл будет удален с CDN и с таблицы, по умолчанию 7 дней, не допускается использование слишком больших значений"
"description": "Ttl в секундах, после которого файл будет удален с CDN и с таблицы, по умолчанию 7 дней, не допускается использование слишком больших значений"
}
]
},
@@ -3740,35 +3740,35 @@ export const protoLayers = [
"name": "size",
"type": "uint32",
"optional": true,
"description": "размер файла"
"description": "Размер файла"
},
{
"id": 2,
"name": "objectId",
"type": "string",
"optional": false,
"description": "выделенный сервером objectId"
"description": "Выделенный сервером objectId"
},
{
"id": 3,
"name": "chunkLength",
"type": "uint32",
"optional": true,
"description": "размер чанка"
"description": "Размер чанка"
},
{
"id": 4,
"name": "chunks",
"type": "uint32",
"optional": true,
"description": "количество чанков"
"description": "Количество чанков"
},
{
"id": 5,
"name": "host",
"type": "string",
"optional": true,
"description": "сервер с которого скачивать файл"
"description": "Сервер с которого скачивать файл"
}
]
},
@@ -3783,21 +3783,21 @@ export const protoLayers = [
"name": "objectId",
"type": "string",
"optional": false,
"description": "выделенный сервером objectId при отправке пакета UploadCandidate"
"description": "Выделенный сервером objectId при отправке пакета UploadCandidate"
},
{
"id": 2,
"name": "blurhash",
"type": "string",
"optional": false,
"description": "превью аватара, строка blurhash"
"description": "Превью аватара, строка blurhash"
},
{
"id": 3,
"name": "mediaKey",
"type": "string",
"optional": false,
"description": "ключ шифрования"
"description": "Ключ шифрования"
}
]
},
@@ -3812,7 +3812,7 @@ export const protoLayers = [
"name": "objectId",
"type": "string",
"optional": false,
"description": "objectId аватара, аватар или фотография профиля должна при этом стоять у профиля отправителя пакета"
"description": "ObjectId аватара, аватар или фотография профиля должна при этом стоять у профиля отправителя пакета"
}
]
},

View File

@@ -2,6 +2,42 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.tsx'
const PRELOAD_RELOAD_KEY = 'rosetta:preload-reload-at'
const PRELOAD_RELOAD_COOLDOWN = 10_000
if (import.meta.env.PROD) {
window.addEventListener('vite:preloadError', (event) => {
let lastReload = 0
try {
lastReload = Number(window.sessionStorage.getItem(PRELOAD_RELOAD_KEY) || 0)
} catch {
// Storage can be unavailable in restricted browser modes.
}
if (Date.now() - lastReload < PRELOAD_RELOAD_COOLDOWN) {
return
}
event.preventDefault()
try {
window.sessionStorage.setItem(PRELOAD_RELOAD_KEY, String(Date.now()))
} catch {
// Reload recovery still works when storage is unavailable.
}
window.location.reload()
})
window.setTimeout(() => {
try {
window.sessionStorage.removeItem(PRELOAD_RELOAD_KEY)
} catch {
// Nothing to clear when storage is unavailable.
}
}, PRELOAD_RELOAD_COOLDOWN)
}
createRoot(document.getElementById('root')!).render(
<BrowserRouter>

View File

@@ -31,9 +31,9 @@
.callout {
margin: 18px 0 26px;
border: 1px solid light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-9));
border-left: 3px solid var(--mantine-color-blue-6);
background: light-dark(var(--mantine-color-blue-0), rgba(25, 113, 194, 0.12));
border: 1px solid light-dark(var(--mantine-color-red-3), var(--mantine-color-red-9));
border-left: 3px solid var(--mantine-color-red-6);
background: light-dark(var(--mantine-color-red-0), rgba(250, 82, 82, 0.1));
}
.vectorGroup {
@@ -58,17 +58,21 @@
}
.marker {
padding: 1px 3px;
background: light-dark(var(--mantine-color-blue-1), rgba(25, 113, 194, 0.24));
color: inherit;
padding: 0 0.14em;
border-radius: 2px;
background: light-dark(rgba(250, 82, 82, 0.13), rgba(250, 82, 82, 0.2));
color: light-dark(var(--mantine-color-red-9), var(--mantine-color-red-2));
font-weight: inherit;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.result {
margin: 18px 0;
padding: 12px 14px;
border-left: 3px solid var(--mantine-color-blue-6);
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-blue-9), var(--mantine-color-blue-2));
border-left: 3px solid var(--mantine-color-red-6);
background: light-dark(var(--mantine-color-red-0), rgba(250, 82, 82, 0.1));
color: light-dark(var(--mantine-color-red-9), var(--mantine-color-red-2));
font-size: var(--mantine-font-size-sm);
font-weight: 650;
font-weight: 400;
}

View File

@@ -185,10 +185,11 @@ keyEnvelope = Base64(iv || AES-CBC(wrappingKey, messageSecret) || ephemeral.publ
</Box>
<LegalText>
The result contains 13 encrypted content bytes followed by the 16-byte
Poly1305 authentication tag.{' '}
Poly1305 authentication tag. Any change to either part causes{' '}
<Text component="span" className={classes.marker}>
Any change to either part causes authenticated decryption to fail.
authenticated decryption to fail
</Text>
.
</LegalText>
<Title order={3} className={classes.subheading}>

View File

@@ -1,4 +1,7 @@
.protoCode {
width: 100%;
min-width: 0;
max-width: 100%;
margin: 18px 0;
padding: 16px;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
@@ -23,18 +26,37 @@
font-weight: 650;
}
.important {
padding: 0 0.14em;
border-radius: 2px;
background: light-dark(rgba(250, 82, 82, 0.13), rgba(250, 82, 82, 0.2));
color: light-dark(var(--mantine-color-red-9), var(--mantine-color-red-2));
font-weight: inherit;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.layerExamples {
min-width: 0;
margin: 18px 0 24px;
}
.layerExamples > * {
min-width: 0;
}
.layerExamples .protoCode {
height: calc(100% - 30px);
margin-bottom: 0;
}
.docsLinks {
margin-top: 44px;
gap: 28px;
}
.docsLink {
display: inline-block;
margin-top: 44px;
font-size: var(--mantine-font-size-sm);
font-weight: 600;
text-decoration: none;
@@ -51,6 +73,8 @@
.docsGrid {
display: grid;
width: 100%;
min-width: 0;
grid-template-columns: 220px minmax(0, 1fr);
gap: 44px;
align-items: start;
@@ -59,6 +83,7 @@
.sidebar {
position: sticky;
top: 92px;
min-width: 0;
max-height: calc(100vh - 112px);
padding-right: 10px;
overflow-y: auto;
@@ -67,8 +92,10 @@
.sidebar a {
width: fit-content;
max-width: 100%;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
text-decoration: none;
overflow-wrap: anywhere;
}
.sidebar a:hover {
@@ -77,6 +104,7 @@
}
.layerDescription {
min-width: 0;
padding: 12px;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
border-radius: var(--mantine-radius-xs);
@@ -85,7 +113,9 @@
.layerDescription code {
display: inline-block;
max-width: 100%;
font-size: 11px;
overflow-wrap: anywhere;
}
.documentation {
@@ -100,6 +130,7 @@
.schemaSection {
scroll-margin-top: 96px;
min-width: 0;
margin-top: 64px;
}
@@ -112,6 +143,7 @@
.definition {
scroll-margin-top: 96px;
min-width: 0;
margin-top: 38px;
padding-top: 32px;
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
@@ -126,6 +158,8 @@
}
.yamlDetails {
min-width: 0;
max-width: 100%;
margin: 20px 0;
}
@@ -139,6 +173,8 @@
}
.yamlBlock {
min-width: 0;
max-width: 100%;
margin-top: 10px;
padding: 12px 12px 16px 18px;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
@@ -148,6 +184,9 @@
.yamlBlock code {
display: block;
width: 100%;
min-width: 0;
max-width: 100%;
padding: 0;
background: transparent;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
@@ -162,6 +201,12 @@
border-radius: var(--mantine-radius-xs);
}
.tableScroll {
width: 100%;
min-width: 0;
max-width: 100%;
}
.table th {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
}
@@ -179,7 +224,7 @@
@media (max-width: 62em) {
.docsGrid {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);
}
.sidebar {

View File

@@ -145,7 +145,7 @@ function FieldTable({
}
return (
<Table.ScrollContainer minWidth={620}>
<Table.ScrollContainer minWidth={620} className={classes.tableScroll}>
<Table verticalSpacing="sm" horizontalSpacing="md" className={classes.table}>
<Table.Thead>
<Table.Tr>
@@ -285,7 +285,7 @@ function EnumDefinition({ item, layer }: { item: ProtoEnum; layer: ProtoLayer })
{item.description || `Enumeration ${item.name} in protocol layer ${layer.version}.`}
</Text>
<HiddenYaml value={item.yaml} />
<Table.ScrollContainer minWidth={520}>
<Table.ScrollContainer minWidth={520} className={classes.tableScroll}>
<Table verticalSpacing="sm" horizontalSpacing="md" className={classes.table}>
<Table.Thead>
<Table.Tr>
@@ -517,6 +517,56 @@ export class RccGeneratedPacketRegistry {
</Code>
</LegalSection>
<LegalSection title="RCC: one schema contract, two generation modes">
<LegalText>
Rosetta Code Compiler (RCC) is the build-time compiler behind the protocol.
It reads every <Code>layer.N.yml</Code> snapshot, turns the YAML into a
typed protocol model, validates that the history is safe, and emits source
code for the selected language. Applications import generated models and
codecs; they do not parse the YAML at runtime.
</LegalText>
<Code block className={classes.protoCode}>
{`proto/layer.*.yml
│
├─ parse packets, reusable types, enums and comments
├─ validate references, required fields and stable field IDs
│
├─ mode=server → all layers + version-aware codecs + registry
└─ mode=client → latest layer + latest codecs + version constant`}
</Code>
<LegalList>
<List.Item>
<strong>Server generation</strong> preserves the protocol timeline.
Java codecs receive the negotiated layer, include only fields active in
that layer, and expose the compiled minimum and maximum versions.
</List.Item>
<List.Item>
<strong>Client generation</strong> selects the{' '}
<Text component="span" className={classes.important}>
highest layer
</Text>
{' '}and creates the current packet, type, enum, codec, and registry
sources without carrying historical shapes into the application.
</List.Item>
<List.Item>
<strong>Schema validation</strong> fails the build for unknown types or
unsafe field-ID reuse, so incompatible protocol edits are caught before
generated sources reach a server or client package.
</List.Item>
<List.Item>
<strong>Generated documentation</strong> carries schema comments into
Javadoc or TSDoc, including field direction, wire type, optionality, and
accessor contracts.
</List.Item>
</LegalList>
<LegalText>
The RCC guide includes separate client and version-aware server examples.{' '}
Run <Code className={classes.important}>npx rcc --list-languages</Code> for
the authoritative list of language targets in the installed compiler
version.
</LegalText>
</LegalSection>
<LegalSection title="How an RCC YAML layer is structured">
<LegalText>
RCC reads versioned files named <Code>layer.N.yml</Code>. The top-level
@@ -624,7 +674,7 @@ reader.getInt8(fieldId: 1) -> ResultCode.SUCCESS`}
^^^^^ ^^^^^^^^
packet field 1 · INT8 · SUCCESS (0)`}
</Code>
<Table.ScrollContainer minWidth={560}>
<Table.ScrollContainer minWidth={560} className={classes.tableScroll}>
<Table verticalSpacing="sm" horizontalSpacing="md" className={classes.comparisonTable}>
<Table.Thead>
<Table.Tr>
@@ -664,9 +714,14 @@ packet field 1 · INT8 · SUCCESS (0)`}
</LegalText>
</LegalSection>
<Anchor component={Link} to="/proto/docs" className={classes.docsLink}>
See docs of PROTO →
</Anchor>
<Group className={classes.docsLinks}>
<Anchor component={Link} to="/proto/docs" className={classes.docsLink}>
See docs of PROTO →
</Anchor>
<Anchor component={Link} to="/rcc" className={classes.docsLink}>
See docs of RCC →
</Anchor>
</Group>
</LegalPage>
</>
);

View File

@@ -0,0 +1,119 @@
.code {
margin: 18px 0;
padding: 16px;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
border-radius: var(--mantine-radius-xs);
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
font-size: 12px;
line-height: 1.65;
overflow-x: auto;
white-space: pre;
}
.pipeline {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
gap: 12px;
align-items: center;
margin: 22px 0;
}
.pipeline > span:nth-child(odd) {
padding: 14px;
border: 1px solid light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-9));
border-radius: var(--mantine-radius-xs);
background: light-dark(var(--mantine-color-blue-0), rgba(25, 113, 194, 0.12));
color: light-dark(var(--mantine-color-blue-9), var(--mantine-color-blue-1));
font-size: var(--mantine-font-size-sm);
font-weight: 650;
line-height: 1.45;
text-align: center;
}
.pipeline > span:nth-child(even) {
color: var(--mantine-color-blue-6);
font-weight: 700;
}
.step {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
gap: 16px;
margin-top: 30px;
}
.step:first-child {
margin-top: 0;
}
.stepNumber {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 50%;
background: var(--mantine-color-blue-6);
color: var(--mantine-color-white);
font-size: var(--mantine-font-size-sm);
font-weight: 700;
}
.stepBody {
min-width: 0;
padding-bottom: 24px;
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.step:last-child .stepBody {
padding-bottom: 0;
border-bottom: 0;
}
.subheading {
margin: 3px 0 12px;
color: light-dark(var(--mantine-color-dark-8), var(--mantine-color-dark-0));
font-size: 17px;
font-weight: 650;
}
.important {
padding: 0 0.14em;
border-radius: 2px;
background: light-dark(rgba(250, 82, 82, 0.13), rgba(250, 82, 82, 0.2));
color: light-dark(var(--mantine-color-red-9), var(--mantine-color-red-2));
font-weight: inherit;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.note {
margin: 18px 0;
border: 1px solid light-dark(var(--mantine-color-red-3), var(--mantine-color-red-9));
border-left: 3px solid var(--mantine-color-red-6);
background: light-dark(var(--mantine-color-red-0), rgba(250, 82, 82, 0.1));
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
}
@media (max-width: 48em) {
.pipeline {
grid-template-columns: minmax(0, 1fr);
}
.pipeline > span:nth-child(even) {
transform: rotate(90deg);
text-align: center;
}
}
@media (max-width: 36em) {
.step {
grid-template-columns: 28px minmax(0, 1fr);
gap: 12px;
}
.stepNumber {
width: 28px;
height: 28px;
}
}

249
src/pages/RccPage.tsx Normal file
View File

@@ -0,0 +1,249 @@
import { Anchor, Box, Code, List, Paper, Text, Title } from '@mantine/core';
import type { ReactNode } from 'react';
import {
LegalList,
LegalPage,
LegalSection,
LegalText,
} from '../components/LegalPage/LegalPage';
import { SEO } from '../components/SEO/SEO';
import classes from './RccPage.module.css';
const GITEA_REPOSITORY = 'https://git.rosetta.im/Rosetta/rcc';
function TechnicalCode({ children }: { children: string }) {
return (
<Code block className={classes.code}>
{children}
</Code>
);
}
function Step({ number, title, children }: { number: number; title: string; children: ReactNode }) {
return (
<Box className={classes.step}>
<Text className={classes.stepNumber}>{number}</Text>
<Box className={classes.stepBody}>
<Title order={3} className={classes.subheading}>
{title}
</Title>
{children}
</Box>
</Box>
);
}
export function RccPage() {
return (
<>
<SEO
title="Rosetta Code Compiler (RCC)"
description="Install RCC and generate validated protocol models, codecs, and registries from versioned Rosetta YAML schemas."
canonical="https://rosetta.im/rcc/"
keywords="RCC, Rosetta Code Compiler, code generation, protocol codecs, YAML protocol"
/>
<LegalPage eyebrow="Developer tooling" title="Rosetta Code Compiler">
<LegalText lead>
RCC compiles Rosetta&apos;s versioned YAML protocol into typed models, codecs,
and packet registries. A server build can retain the{' '}
<Text component="span" className={classes.important}>
complete protocol history
</Text>
; a client build stays small by generating only the{' '}
<Text component="span" className={classes.important}>
latest layer
</Text>
.
</LegalText>
<LegalSection title="What RCC produces">
<LegalText>
RCC treats <Code>proto/layer.*.yml</Code> as the protocol&apos;s source of
truth. Before writing code, it resolves primitive, enum, and reusable type
references and rejects incompatible reuse of a field ID between layers.
</LegalText>
<Box className={classes.pipeline}>
<Text component="span">Versioned YAML schemas</Text>
<Text component="span" aria-hidden="true">→</Text>
<Text component="span">Parse + validate</Text>
<Text component="span" aria-hidden="true">→</Text>
<Text component="span">Models + codecs + registry</Text>
</Box>
<LegalList>
<List.Item>
Packets become typed packet classes; reusable schema types become model
classes; enums preserve their numeric wire values.
</List.Item>
<List.Item>
Codecs encode and decode TLV fields and verify required fields before
encoding and after decoding.
</List.Item>
<List.Item>
The generated registry maps packet IDs to codecs and exposes the
compiled protocol version or version range.
</List.Item>
<List.Item>
YAML comments become Javadoc or TSDoc for generated declarations and
accessors.
</List.Item>
</LegalList>
</LegalSection>
<LegalSection title="Install RCC from Rosetta Gitea">
<LegalText>
Add Rosetta&apos;s npm package registry to the project, then install RCC. The
registry setting can live in a project-level <Code>.npmrc</Code> so CI and
local builds resolve the same compiler package.
</LegalText>
<TechnicalCode>{`# .npmrc
registry=https://git.rosetta.im/api/packages/Rosetta/npm/
# configure read-only registry authentication
npm config set -- \\
'//git.rosetta.im/api/packages/Rosetta/npm/:_authToken' \\
'b5e64acbf1fdbe2d6d2bc1fac95333ef6f905fff'
# install the compiler
npm install rcc
# query languages from the installed RCC version
npx rcc --list-languages
# inspect the rest of the CLI
npx rcc --help`}</TechnicalCode>
<LegalText>
RCC source is hosted in the{' '}
<Anchor href={GITEA_REPOSITORY} target="_blank" rel="noreferrer">
Rosetta/rcc Gitea repository
</Anchor>
.{' '}
<Text component="span" className={classes.important}>
Pin the dependency version
</Text>
{' '}in the project lockfile so generated output remains reproducible.
</LegalText>
</LegalSection>
<LegalSection title="Generate from schemas, step by step">
<Step number={1} title="Create versioned schema layers">
<LegalText>
Put complete protocol snapshots in one directory and name them
<Code> layer.N.yml</Code>. Increment the top-level version, keep packet
and field IDs stable, and repeat definitions that remain part of the
protocol.
</LegalText>
<TechnicalCode>{`proto/
├── layer.1.yml
├── layer.2.yml
└── layer.3.yml`}</TechnicalCode>
</Step>
<Step number={2} title="Choose client or server output">
<LegalText>
Use <Code>client</Code> for the current application package. RCC selects
the highest schema version. Use <Code>server</Code> when the runtime must
decode connections negotiated on older supported layers. Full
multi-version codec generation is currently implemented by the Java
backend.
</LegalText>
</Step>
<Step number={3} title="Generate Java sources">
<LegalText>
Server generation combines all layers and emits version-aware codecs,
models, enums, and a registry with <Code>MIN_VERSION</Code> and
<Code> MAX_VERSION</Code>.
</LegalText>
<TechnicalCode>{`npx rcc \\
--mode server \\
--lang java \\
--protoDir ./proto \\
--outDir ./server/src/generated/java \\
--basePackage im.rosetta.network \\
--clean`}</TechnicalCode>
<LegalText>
For a Java client, change the mode and output directory. The generated
sources describe only the latest layer.
</LegalText>
<TechnicalCode>{`npx rcc \\
--mode client \\
--lang java \\
--protoDir ./proto \\
--outDir ./client/src/generated/java \\
--basePackage im.rosetta.network \\
--clean`}</TechnicalCode>
</Step>
<Step number={4} title="Generate TypeScript sources">
<LegalText>
TypeScript is the current latest-layer client target. It produces
models, enums, codecs, and <Code>RccGeneratedPacketRegistry.VERSION</Code>.
</LegalText>
<TechnicalCode>{`npx rcc \\
--mode client \\
--lang typescript \\
--protoDir ./proto \\
--outDir ./client/src/generated/protocol \\
--clean`}</TechnicalCode>
<Paper className={classes.note} radius="sm" p="md">
<Text size="sm" lh={1.7}>
Always run{' '}
<Code className={classes.important}>npx rcc --list-languages</Code>{' '}
before configuring a generator. The installed compiler&apos;s output is
the source of truth for available language targets.
</Text>
</Paper>
</Step>
<Step number={5} title="Run all targets from one prebuild config">
<LegalText>
Keep client and server generation together in
<Code> rcc-prebuild.yml</Code>. Paths are resolved relative to the
config file; <Code>clean: true</Code> clears only each configured output
directory.
</LegalText>
<TechnicalCode>{`watch: false
protoDir: ./proto
clean: true
compilers:
- name: TypeScript client
mode: client
lang: typescript
output: ./client/src/generated/protocol
- name: Java server
mode: server
lang: java
output: ./server/src/generated/java
basePackage: im.rosetta.network`}</TechnicalCode>
<TechnicalCode>{`npx rcc -f ./rcc-prebuild.yml`}</TechnicalCode>
</Step>
<Step number={6} title="Compile and package the generated sources">
<LegalText>
Add the output directory to the language build, then run generation
before compilation in local development and CI. RCC writes source
files; the surrounding Gradle, npm, or application build is responsible
for compiling and publishing the final server or client package.
</LegalText>
</Step>
</LegalSection>
<LegalSection title="CLI reference">
<TechnicalCode>{`npx rcc --mode <client|server> [options]
npx rcc -f <filename.yml>
npx rcc --list-languages
--mode, -m client | server
--lang, -l language returned by --list-languages
--protoDir, -p schema directory (default: ./proto)
--outDir, -o generated source directory (default: ./generated)
--basePackage, -b Java base package (default: im.rosetta.generated)
--clean clean the output directory before generation
--file, -f prebuild YAML config`}</TechnicalCode>
</LegalSection>
</LegalPage>
</>
);
}

View File

@@ -1,3 +1,18 @@
function withFreshHtml(response) {
if (!response.headers.get('content-type')?.includes('text/html')) {
return response;
}
const headers = new Headers(response.headers);
headers.set('Cache-Control', 'no-cache');
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
const worker = {
async fetch(request, env) {
const response = await env.ASSETS.fetch(request);
@@ -7,11 +22,12 @@ const worker = {
request.method !== 'GET' ||
!request.headers.get('accept')?.includes('text/html')
) {
return response;
return withFreshHtml(response);
}
const indexUrl = new URL('/index.html', request.url);
return env.ASSETS.fetch(new Request(indexUrl, request));
const indexResponse = await env.ASSETS.fetch(new Request(indexUrl, request));
return withFreshHtml(indexResponse);
},
};