Бывает что на руках есть лишь «бинарная» сборка сайта на модном фреймворке вроде Angular или React, в которой «срочно надо что‑то поправить». А исходного кода нет. Есть лишь вы, «бандл» с обфрусцированным JavaScript‑кодом внутри и горящие сроки. Рассказываю что с этим можно cделать кроме увольнения.
![Процесс восстановления исходников из "source map" как есть. Процесс восстановления исходников из "source map" как есть.](https://habrastorage.org/getpro/habr/upload_files/45b/0cd/220/45b0cd22043d6bb99b2aefdcb705e2ea.jpg)
Проблема
Как-то так получилось, что связка из Typescript и упаковщиков вроде Webpack захватила современную веб-разработку практически целиком, а модель построения веб-приложений «Single Page Application» (SPA) стала применяться для всего вообще — от простейших лендингов и сайтов-визиток до сложных CRM-систем с динамической подгрузкой данных.
Из-за того что Typescript является компилируемым языком, в котором существует разделение на исходный код и конечный код, обфрусцированный и упакованный в специальные «бандлы» — произошло некое смешение смыслов:
Многие заказчики понятия не имеют, что ныне даже у статичных лендингов и сайтов-визиток могут быть исходники.
Особенно если пришли из веб-разработки начала 2000х, когда был кругом статичный HTML, а весь JavaScript-код был очень простым и вставлялся прямо на страницу.
Более того, поскольку результат сборки с помощью Webpack это внезапно тоже код, некоторые особо хитрые джентельмены умудрялись сдавать проекты в виде конечной сборки и набора бандлов, без предоставления реальных исходников, мотивируя тем что «это и есть рабочий исходный код».
И с точки зрения пунктов договора на разработку они вообщем-то были правы.
Вот вам небольшой пример такой сборки, взятый с сайта JHipster, чтобы было понятно о чем речь:
и(()=>{"use strict";var e,v={},m={};function r(e){var i=m[e];if(void 0!==i)return i.exports;var t=m[e]={exports:{}};return v[e](t,t.exports,r),t.exports}r.m=v,e=[],r.O=(i,t,f,o)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,f,o]=e[n],c=!0,u=0;u<t.length;u++)(!1&o||a>=o)&&Object.keys(r.O).every(p=>r.O[p](t[u]))?t.splice(u--,1):(c=!1,o<a&&(a=o));if(c){e.splice(n--,1);var l=f();void 0!==l&&(i=l)}}return i}o=o||0;for(var n=e.length;n>0&&e[n-1][2]>o;n--)e[n]=e[n-1];e[n]=[t,f,o]},r.d=(e,i)=>{for(var t in i)r.o(i,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:i[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((i,t)=>(r.f[t](e,i),i),[])),r.u=e=>(592===e?"common":e)+"."+{127:"3939584411b29233",146:"9e6e63e24ba057f5",462:"3c011262c4aafd67",592:"1a0b39952c0d48d5",679:"dc91bdcb440d5d58",792:"f4d5b583a515fb1b",848:"b617a814d1d8d8d1",905:"e873b5c1adf6b3c3",920:"a0a741fb2015a1e4",972:"bd8f95f56699519f",994:"4c7d5415c98549f2"}[e]+".js",r.miniCssF=e=>{},r.o=(e,i)=>Object.prototype.hasOwnProperty.call(e,i),(()=>{var e={},i="jhonline:";r.l=(t,f,o,n)=>{if(e[t])e[t].push(f);else{var a,c;if(void 0!==o)for(var u=document.getElementsByTagName("script"),l=0;l<u.length;l++){var d=u[l];if(d.getAttribute("src")==t||d.getAttribute("data-webpack")==i+o){a=d;break}}a||(c=!0,(a=document.createElement("script")).type="module",a.charset="utf-8",a.timeout=120,r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",i+o),a.src=r.tu(t)),e[t]=[f];var b=(g,p)=>{a.onerror=a.onload=null,clearTimeout(s);var h=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),h&&h.forEach(y=>y(p)),g)return g(p)},s=setTimeout(b.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=b.bind(null,a.onerror),a.onload=b.bind(null,a.onload),c&&document.head.appendChild(a)}}})(),r.r=e=>{typeof Symbol<"u"&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tt=()=>(void 0===e&&(e={createScriptURL:i=>i},typeof trustedTypes<"u"&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e)})(),r.tu=e=>r.tt().createScriptURL(e),r.p="",(()=>{var e={666:0};r.f.j=(f,o)=>{var n=r.o(e,f)?e[f]:void 0;if(0!==n)if(n)o.push(n[2]);else if(666!=f){var a=new Promise((d,b)=>n=e[f]=[d,b]);o.push(n[2]=a);var c=r.p+r.u(f),u=new Error;r.l(c,d=>{if(r.o(e,f)&&(0!==(n=e[f])&&(e[f]=void 0),n)){var b=d&&("load"===d.type?"missing":d.type),s=d&&d.target&&d.target.src;u.message="Loading chunk "+f+" failed.\n("+b+": "+s+")",u.name="ChunkLoadError",u.type=b,u.request=s,n[1](u)}},"chunk-"+f,f)}else e[f]=0},r.O.j=f=>0===e[f];var i=(f,o)=>{var u,l,[n,a,c]=o,d=0;if(n.some(s=>0!==e[s])){for(u in a)r.o(a,u)&&(r.m[u]=a[u]);if(c)var b=c(r)}for(f&&f(o);d<n.length;d++)r.o(e,l=n[d])&&e[l]&&e[l][0](),e[l]=0;return r.O(b)},t=self.webpackChunkjhonline=self.webpackChunkjhonline||[];t.forEach(i.bind(null,0)),t.push=i.bind(null,t.push.bind(t))})()})();
Это «стандартный» загрузчик модулей после работы упаковщика Webpack.
А вот так выглядит этот же код после частичного восстановления декомпилятором (который на самом деле больше деобфрускатор):
(() => {
"use strict";
var e, v = {},
m = {};
function r(e) {
var i = m[e];
if (void 0 !== i) return i.exports;
var t = m[e] = {
exports: {}
};
return v[e](t, t.exports, r), t.exports
}
r.m = v, e = [], r.O = (i, t, f, o) => {
if (!t) {
var a = 1 / 0;
for (n = 0; n < e.length; n++) {
for (var [t, f, o] = e[n], c = !0, u = 0; u < t.length; u++)(!1 & o || a >= o) && Object.keys(r.O).every(p => r.O[p](t[u])) ? t.splice(u--, 1) : (c = !1, o < a && (a = o));
if (c) {
e.splice(n--, 1);
var l = f();
void 0 !== l && (i = l)
}
}
return i
}
o = o || 0;
for (var n = e.length; n > 0 && e[n - 1][2] > o; n--) e[n] = e[n - 1];
e[n] = [t, f, o]
}, r.d = (e, i) => {
for (var t in i) r.o(i, t) && !r.o(e, t) && Object.defineProperty(e, t, {
enumerable: !0,
get: i[t]
})
}, r.f = {}, r.e = e => Promise.all(Object.keys(r.f).reduce((i, t) => (r.f[t](e, i), i), [])), r.u = e => (592 === e ? "common" : e) + "." + {
127: "3939584411b29233",
146: "9e6e63e24ba057f5",
462: "3c011262c4aafd67",
592: "1a0b39952c0d48d5",
679: "dc91bdcb440d5d58",
792: "f4d5b583a515fb1b",
848: "b617a814d1d8d8d1",
905: "e873b5c1adf6b3c3",
920: "a0a741fb2015a1e4",
972: "bd8f95f56699519f",
994: "4c7d5415c98549f2"
} [e] + ".js", r.miniCssF = e => {}, r.o = (e, i) => Object.prototype.hasOwnProperty.call(e, i), (() => {
var e = {},
i = "jhonline:";
r.l = (t, f, o, n) => {
if (e[t]) e[t].push(f);
else {
var a, c;
if (void 0 !== o)
for (var u = document.getElementsByTagName("script"), l = 0; l < u.length; l++) {
var d = u[l];
if (d.getAttribute("src") == t || d.getAttribute("data-webpack") == i + o) {
a = d;
break
}
}
a || (c = !0, (a = document.createElement("script")).type = "module", a.charset = "utf-8", a.timeout = 120, r.nc && a.setAttribute("nonce", r.nc), a.setAttribute("data-webpack", i + o), a.src = r.tu(t)), e[t] = [f];
var b = (g, p) => {
a.onerror = a.onload = null, clearTimeout(s);
var h = e[t];
if (delete e[t], a.parentNode && a.parentNode.removeChild(a), h && h.forEach(y => y(p)), g) return g(p)
},
s = setTimeout(b.bind(null, void 0, {
type: "timeout",
target: a
}), 12e4);
a.onerror = b.bind(null, a.onerror), a.onload = b.bind(null, a.onload), c && document.head.appendChild(a)
}
}
})(), r.r = e => {
typeof Symbol < "u" && Symbol.toStringTag && Object.defineProperty(e, Symbol.toStringTag, {
value: "Module"
}), Object.defineProperty(e, "__esModule", {
value: !0
})
}, (() => {
var e;
r.tt = () => (void 0 === e && (e = {
createScriptURL: i => i
}, typeof trustedTypes < "u" && trustedTypes.createPolicy && (e = trustedTypes.createPolicy("angular#bundler", e))), e)
})(), r.tu = e => r.tt().createScriptURL(e), r.p = "", (() => {
var e = {
666: 0
};
r.f.j = (f, o) => {
var n = r.o(e, f) ? e[f] : void 0;
if (0 !== n)
if (n) o.push(n[2]);
else if (666 != f) {
var a = new Promise((d, b) => n = e[f] = [d, b]);
o.push(n[2] = a);
var c = r.p + r.u(f),
u = new Error;
r.l(c, d => {
if (r.o(e, f) && (0 !== (n = e[f]) && (e[f] = void 0), n)) {
var b = d && ("load" === d.type ? "missing" : d.type),
s = d && d.target && d.target.src;
u.message = "Loading chunk " + f + " failed.\n(" + b + ": " + s + ")", u.name = "ChunkLoadError", u.type = b, u.request = s, n[1](u)
}
}, "chunk-" + f, f)
} else e[f] = 0
}, r.O.j = f => 0 === e[f];
var i = (f, o) => {
var u, l, [n, a, c] = o,
d = 0;
if (n.some(s => 0 !== e[s])) {
for (u in a) r.o(a, u) && (r.m[u] = a[u]);
if (c) var b = c(r)
}
for (f && f(o); d < n.length; d++) r.o(e, l = n[d]) && e[l] && e[l][0](), e[l] = 0;
return r.O(b)
},
t = self.webpackChunkjhonline = self.webpackChunkjhonline || [];
t.forEach(i.bind(null, 0)), t.push = i.bind(null, t.push.bind(t))
})()
})();
Думаю очевидно что попытка сопровождать такой код вашими хилыми силами это примерно как попытка участия 100кг туши в балете — и то и другое хотя и теоретически возможно, но врядли продлится долго.
Другой пример
Допустим вы сделали самый обычный «сайт‑визитку» для стартапа, через полгода стартап внезапно выстреливает и идет волна заказов.
Теперь сайт по-хорошему надо делать заново, но времени нет, а старый (он же текущий) при этом отключать нельзя — надо туда постоянно вносить мелкие правки текста: новые контакты, правила, адреса, ссылки и так далее.
И правок этих будет миллион.
А исходников нет. Забыли, потеряли, пролюбили в хаосе начинающей компании — кто работал в стартапах тот поймет.
И вот вы уже в позиции «вечной Золушки», вынуждены снова и снова «добавлять мелкие правки» в некогда статичный сайт прямо на ходу.
Тестовый пример
К сожалению не получится использовать один из наших рабочих проектов в качестве примера для этой статьи — Webpack генерирует чудовищного размера сборки для более-менее объемного проекта, разбираться в которых будет слишком уж долго.
Поэтому был взят шаблон лендинга на React, посвежее и более менее похожий на то что бывает в реальности. И сейчас я покажу на нем что и как можно сделать в столь печальной ситуации.
Выглядит оригинальный шаблон не без изысков вот так:
![Цвет фона медленно меняется а звезды двигаются - автор хотел показать свое мастерство. Цвет фона медленно меняется а звезды двигаются - автор хотел показать свое мастерство.](https://habrastorage.org/getpro/habr/upload_files/bf2/e06/a1d/bf2e06a1d119e1a5d56cb9be4ae029ac.png)
Сам проект технически вообщем-то тривиален, а его cборка максимально упрощена:
git clone https://github.com/chenkoufan/HashirPortfolio.git
npm install
npm run build
И собственно.. все, в каталоге build будет готовая сборка сайта.
Единственное что я добавил в проект — указание на корневой путь «/», поскольку по-умолчанию в шаблоне используется «/home».
Для этого создаете файл .env в корне проекта и вставляете:
PUBLIC_URL=/
Больше про эту настройку тут.
В папке build будет релизная сборка, а в build/js — те самые бандлы.
Каталог build со всем содержимым и будет выступать нашим тестовым стендом, на котором я буду показывать все чудеса эквилибристики с декомпиляторами и деобфрускаторами.
Но начнем мы все же с немного другого, поскольку самый короткий путь — часто самый лучший.
Восстановление исходников из .git
Очень и очень многие современные разработчики — скажем прямо "не отличаются умом и сообразительностью", поэтому не могут натворить бед работодателю даже когда им очень этого хочется.
Поэтому действительно на практике случаются ситуации экстремального дебилизма, хорошо показанные в фильмах Гая Ричи.
Например история вот этих парней:
![Нет, эти даже поумнее некоторых моих коллег по отрасли, если честно. Нет, эти даже поумнее некоторых моих коллег по отрасли, если честно.](https://habrastorage.org/getpro/habr/upload_files/6cc/1eb/5b5/6cc1eb5b53043068dc04bcf04d5c7ef6.jpeg)
Да, вы правильно поняли — все это про сохранение каталога .git одновременно с попыткой удаления файлов исходников, например с целью шантажа работодателя. Такое тоже бывает в жизни, причем чаще чем вы думаете.
Если кто вдруг еще не знает то сообщаю:
в каталоге .git хранится полная копия всего вашего исходного кода, еще и с историей всех изменений
Потому что это часть системы контроля версий, так она работает.
Было:
![Я взял и удалил все папки с исходниками, оставив лишь финальную сборку и каталог .git Я взял и удалил все папки с исходниками, оставив лишь финальную сборку и каталог .git](https://habrastorage.org/getpro/habr/upload_files/2ef/3d5/dbd/2ef3d5dbda063f19d267c7e5a1985712.png)
Запускаем восстановление:
git reset --hard
Стало:
![Вуаля! Все вернулось из небытия. Вуаля! Все вернулось из небытия.](https://habrastorage.org/getpro/habr/upload_files/9c9/38f/c35/9c938fc3534f1ad596e197104bbe955f.png)
Так что если видите папку .git на сервере или в архиве с вашим сайтом — скорее всего жизнь не так плоха и печальна.
«Скорее всего» тут по той простой причине, что бывают варианты, когда git используется не по прямому назначению, а например для передачи готовых сборок на сервер через сервис CI.
В этом случае фиксироваться будут только изменения в бандлах, а исходники останутся на уровне CI. Но это уже история не про лендинги и сайты-визитки, а про что-то большое, где полная утеря исходников маловероятна.
Восстановление из файлов «source maps»
Следующий рабочий вариант как вытащить исходники React-приложения из небытия — попытаться восстановить их из файлов «source maps».
Подробно про технологию «source map» можно почитать вот тут в оригинале или тут на русском в
переводе Гоблина.
Если кратко, то это такие специальные файлы, которые генерируются при сборке проекта и содержат метаданные по исходному коду. Эти самые медатанные во время работы приложения позволяют формировать корректную трассировку исключений: с номерами строк и читаемыми названиями методов и классов.
Вот так выглядит небольшая часть «source map» файла:
{
"version":3,
"file":
"static/js/main.2911d091.js",
"mappings":";wCAAAA,EAAOC,QAAU,EAAjBD,yCCEA,EAAOmB,KAAKE,SA.."
}
Технически это просто большой JSON, с кучей вложенных объектов и закодированных частей.
Оказалось что метаданных из файлов «source maps» вполне достаточно для восстановления оригинального исходного кода.
Сейчас покажу как это работает.
Инструментов для восстановления исходников из «source map» файлов много разных, я использовал вот такой, в первую очередь из‑за того что он сохраняет сразу на диск все найденное. По этой ссылке находится статья от автора, с детальным описанием работы.
Забираем:
git clone https://github.com/rarecoil/unwebpack-sourcemap.git
и запускаем:
python unwebpack_sourcemap.py -d --make-directory https://hashirshoaeb.com/home/ out
Вариант запуска выше восстановит исходник непостредственно с внешнего сайта автора, а вот так — с локально запущенной копии:
python unwebpack_sourcemap.py -d --make-directory http://localhost:8081/ out2
Я использовал NPM-пакет http-server для эмуляции отдачи статики, поскольку он не связан с отладочным режимом Webpack (когда работает перекомпиляция на лету) и может отдавать только статический контент — получается полная симуляция старого доброго HTTP-сервера для отдачи статики, вроде Apache или Nginx.
Запускается вот так:
http-server ./build
По-умолчанию отдает контент на порту 8081 и использует путь «/», а аргумент «./build» это указание на каталог со статикой, все просто.
Теперь посмотрим что же удалось вытащить из source maps:
![](https://habrastorage.org/getpro/habr/upload_files/373/8a9/508/3738a9508083e7a4f088407b8fa57564.png)
Как видите дофига, настолько дофига, что некоторые коллеги после демонстрации такого восстановления хватаются за сердце и срочно убирают генерацию файлов «source maps» из своих продуктовых сборок.
Вот вам для сравнения оригинальный каталог с исходниками:
![](https://habrastorage.org/getpro/habr/upload_files/42e/37a/f5a/42e37af5ac6c59194a963e39516ee792.png)
Нет лишь статики (картинок и стилей оформления), которая просто выносится при сборке в отдельный каталог:
![](https://habrastorage.org/getpro/habr/upload_files/b2b/1b6/81f/b2b1b681fd68f26f2e5d845adf714f48.png)
Теперь посмотрим внимательно на востановленный исходный код — что именно и до какой степени в нем восстановилось.
Вот восстановленная копия стартового скрипта нашего тестового веб-приложения на React:
![](https://habrastorage.org/getpro/habr/upload_files/aa3/f18/6d7/aa3f186d72f3431a82db145fa22cc357.png)
А вот так выглядит оригинальный файл:
![](https://habrastorage.org/getpro/habr/upload_files/530/b81/fab/530b81fabc06e9e37ff00778ad63dae2.png)
Что тоже в легком удивлении?
В заголовке окна показывается полный путь к файлу, если вы вдруг подумали что я ошибся и дваджы открыл один и тот же файл ;)
Но это еще не все, следующий файл будет с JSX-шаблоном компонента — специально прокрутил до места где он начинается.
Восстановленная копия:
![](https://habrastorage.org/getpro/habr/upload_files/fe1/10d/773/fe110d77395143563046f0ad01a4daf9.png)
А теперь оригинал для сравнения:
![](https://habrastorage.org/getpro/habr/upload_files/1b1/14a/e24/1b114ae24853f3c5cf3e4cb9c5dad9ec.png)
Как видите восстановилось фактически вообще все.
Все исходные файлы, вся структура каталогов, весь исходный код включая даже комментарии и форматирование — спокойно восстанавливается из файлов source maps.
Прекрасный новый мир современных веб-технологий!
Ложка дегтя
К сожалению кое-что все же не восстанавливается: скрипты сборки и внешние пакеты. И то и другое придется собирать по крупицам и создавать заново, что в случае большого проекта легко может стать экстремально сложной задачей.
Тем не менее, такое восстановление исходников из файлов «source maps» это отлично работающий вариант для мелких лендингов и сайтов-визиток, по чьей-то безумной прихоти реализованных на компилируемом языке и столь сложном фреймворке.
Теперь наконец переходим к настоящей жести.
Работа с обфрусцированным бандлом
Да, это именно тот самый вариант, на который меня и подобных товарищей обычно и зовут. Каталога .git нет, никаких «source maps» тоже нет, есть лишь статика в виде набора файлов, а index.html выглядит как-то так:
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="shortcut icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><link href="https://use.fontawesome.com/releases/v5.4.1/css/all.css" rel="stylesheet"/><meta name="theme-color" content="#000000"/><meta name="description" content="My name is Hashir Shoaib. I’m a graduate of 2020 from National University of Sciences and Technology at Islamabad with a degree in Computer Engineering. I'm most passionate about giving back to the community, and my goal is to pursue this passion within the field of software engineering. In my free time I like working on open source projects."/><link rel="apple-touch-icon" href="logo192.png"/><link rel="manifest" href="/manifest.json"/><meta property="twitter:image" content="/social-image.png"/><meta property="og:image" content="/social-image.png"/><title>Hashir Shoaib</title><script defer="defer" src="/static/js/main.2911d091.js"></script><link href="/static/css/main.fe927caf.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
Ну и в наличии сами бандлы на JavaScript, прошедшие через Tree Shaking, минификацию и обфрускацию.
Задачи
Давайте начнем с типичного набора задач, которые ставились лично мне в таких случаях:
изменить текст в нужном месте,
добавить пункт меню в шапке,
добавить кнопку или изменить поведение существующей,
скрыть существующий или добавить блок данных.
Все это напоминаю надо проделать в обфрусцированном бандле, сгенерированным с помощью упаковщика Webpack и без доступа к исходникам.
Немного матчасти
Есть ряд вещей, которые необходимо знать о внутреннем устройстве «упакованной» версии веб-приложения на React прежде чем мы продолжим.
Первое и самое главное:
весь контент, все что вы видите на странице это один сплошной JavaScript, никакого HTML кроме стартового index.html в таком веб-приложении нет.
Для иллюстрации возьмем вот эту красивую кнопку:
![](https://habrastorage.org/getpro/habr/upload_files/c9e/6f9/5ce/c9e6f95ce139ca23422b465ba34ade58.png)
Вот так выглядит восстановленный код (прогнанный через несколько разных деобфрускаторов), который ее создает и показывает:
..
(0, Ge.jsx)("div", {
className: "p-5",
children: o.map((function (e, n) {
return (0, Ge.jsx)("a", {
target: "_blank",
rel: "noopener noreferrer",
href: e.url,
"aria-label": "My ".concat(e.image.split("-")[1]),
children: (0, Ge.jsx)("i", {
className: "fab ".concat(e.image, " fa-3x socialicons")
})
}, "social-icon-".concat(n))
}))
}), (0, Ge.jsx)("a", {
className: "btn btn-outline-light btn-lg ",
href: "#aboutme",
role: "button",
"aria-label": "Learn more about me",
children: "More about me"
})]
})]
..
Как видите к нормальному HTML это отношения не имеет, а правка такой дичи — не имеет ничего общего с обычной версткой.
Второе:
веб-приложение на React максимально изолировано от внешнего окружения.
Отправлять и получать события из кода вне React хоть и возможно но очень сложно и требует определенных действий в самом приложении.
Помимо этого, приложение на React поддерживает внутреннее состояние всех компонентов и не реагирует (по-умолчанию) на изменения в DOM-дереве страницы, произведенные снаружи приложения.
Это одновременно и хорошо и плохо.
Хорошо, потому что дает возможность производить манипуляции c DOM-деревом не влезая внутрь самого приложения.
Плохо, потому что ваши изменения могут быть легко затерты приложением, когда оно решит что «время пришло» и надо обновлять компонент. А обновлять его оно будет разумеется из своего внутреннего состояния.
Так что налучший подход это минимальное вмешательство во внутренности таких приложений и решение задачи малой кровью — снаружи.
Что я сейчас и продемонстрирую.
Начнем с самого простой, но самой частой задачи — с удаления элемента на странице.
Задача первая: "С глаз долой, из сердца вон"
Разумеется физически удалять из DOM-дерева ничего не стоит (помним про внутренее состояние React-приложения), благо есть вариант проще — скрытие ненужного элемента через CSS-стили.
Допустим, нам надо убрать пункт меню «About» из шапки страницы нашего тестового проекта.
Открываем index.html и добавляем новый блок <style></style> внутрь тега <head>, пишем :
#basic-navbar-nav > div.navbar-nav > a:nth-of-type(3) {
display: none;
}
Все вместе должно выглядеть вот так:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link href="https://use.fontawesome.com/releases/v5.4.1/css/all.css" rel="stylesheet" />
<meta name="theme-color" content="#000000" />
<meta name="description"
content="My name is Hashir Shoaib. I’m a graduate of 2020 from National University of Sciences and Technology at Islamabad with a degree in Computer Engineering. I'm most passionate about giving back to the community, and my goal is to pursue this passion within the field of software engineering. In my free time I like working on open source projects." />
<link rel="apple-touch-icon" href="logo192.png" />
<link rel="manifest" href="/manifest.json" />
<meta property="twitter:image" content="/social-image.png" />
<meta property="og:image" content="/social-image.png" />
<title>Hashir Shoaib</title>
<script defer="defer" src="/static/js/main.2911d091.js"></script>
<link href="/static/css/main.fe927caf.css" rel="stylesheet">
<!-- наша коварная вставка -->
<style>
#basic-navbar-nav > div.navbar-nav > a:nth-of-type(3) {
display: none;
}
</style>
</head>
<body><noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
Результат:
![Внимание на пункты меню вверху, раздел "About" пропал Внимание на пункты меню вверху, раздел "About" пропал](https://habrastorage.org/getpro/habr/upload_files/841/7e2/6c5/8417e26c54f713ae698fbfa3d5cc0c48.png)
Теперь рассказываю как и почему это работает.
Если открыть «Developer Tools» в любимом браузере Chrome (клавиша F12) и перейти на вкладку «Elements», можно увидеть что внутри пустого тега
<div id="root"></div>
внезапно появилась жизнь — все что вы визуально можете наблюдать на странице в браузере находится внутри этого самого тега:
![Рабочие будни Рабочие будни](https://habrastorage.org/getpro/habr/upload_files/01b/4aa/83e/01b4aa83ea11f5d8d9254547557c1dd7.png)
То что вы наблюдаете есть результат работы рендера React, который «отрисовывает» каждый компонент приложения внутри корневого элемента согласно его текущему состоянию.
И до тех пор пока в каком-то из скрываемых нами компонентов не произойдет изменения свойства «display» — он так и будет невидимым.
Что касается самого CSS, то используется достаточно сложный селектор, где происходит одновременно выборка по id элемента
#basic-navbar-nav
затем обращение по цепочке ко вложенным элементам
> div.navbar-nav > a
а потом еще и обращение по порядковому номеру
a:nth-of-type(3)
Селектор a:nth-of-type(3) означает что запрашивается третий по порядку элемент <a>. Обращение по уникальному id работает поскольку он был указан в исходном компоненте React:
<Navbar.Collapse id="basic-navbar-nav">
который после стадии рендеринга попадает и в конечный DOM-элемент:
<div class="navbar-collapse collapse"
id="basic-navbar-nav">
<div class="navbar-nav mr-auto navbar-nav">
..
</div>
</div>
Еще один пример для закрепления знаний:
#home > div.container > div.text-center > h1.display-1 {
display: none;
}
Код выше скроет самую большую надпись на странице с именем автора:
![Огромной надписи выше строки "Passionate about.." больше нет. Огромной надписи выше строки "Passionate about.." больше нет.](https://habrastorage.org/getpro/habr/upload_files/dc6/d77/d34/dc6d77d345084c4d35baa68b398acd1c.png)
Думаю этого примера будет достаточно и читателям теперь понятно как и почему оно работает.
Замечу что сей метод и на практике (на больших приложениях) работает «на ура», честно говоря большая часть работы с подобными задачами это и есть столь банальное скрытие элементов: ссылок, кнопок, пунктов меню или просто разделов с данными.
Но это еще не конец статьи, поэтому мы погружаемся глубже в дикий треш.
Задача вторая: "немного поправить текст на странице"
Следущая стадия это скромная просьба «немного изменить текст на странице», напоминаю что речь про обфрусцированный и минимизированный бандл, созданный с помощью упаковщика, о чем разумеется просящие скромно умалчивают.
Тут уже потребуется немного кода на JavaScript, поэтому добавляем тег <script></script> и помолясь начинаем ваять:
let intervalID;
function makeWhenReady() {
let el3 = document.querySelector('span.react-loading-skeleton');
if (!el3) {
let el = document.querySelector('#home > div.container > div.text-center > h1.display-1');
el.innerHTML = "Да здраствует хардкор!";
el.style.display='block';
clearInterval(intervalID);
}
}
window.addEventListener('load', function() {
intervalID = setInterval(makeWhenReady, 500);
});
Вот так выглядит результат работы:
![Результат правки Результат правки](https://habrastorage.org/getpro/habr/upload_files/155/6ec/576/1556ec57694dd449aac0b8bdd1d08c2d.png)
Теперь немного расскажу о том как и почему это работает.
Самое важное что тут следует знать:
React-приложение имеет свою собственную логику загрузки, поэтому стандартные обработчики вроде window.addEventListener() или DOMContentLoaded не cработают.
Попытка поработать с DOM-деревом непосредственно из такого обработчика приведет к тому что вместо данных вы увидите фигу шаблон их загрузки — в проекте используется модуль react-loading-skeleton для создания таких шаблонов.
Поэтому мы поступаем хитрее: устанавливаем свой собственный обработчик на событие load у «окна» браузера:
window.addEventListener('load', function() {
intervalID = setInterval(makeWhenReady, 500);
});
а внутри обработчика уставливаем еще и фоновую обработку — функцию, которая будет вызываться с определенной переодичностью (в нашем случае раз в 500 миллисекунд):
intervalID = setInterval(makeWhenReady, 500);
intervalID как нетрудно догадаться это ее идентификатор, который будет нужен далее для остановки этой фоновой функции.
Внутри этой фоновой функции мы делаем проверку на наличие в DOM-дереве элементов <span> с классом react-loading-skeleton — сие означает что еще не все компоненты загружены:
function makeWhenReady() {
let el3 = document.querySelector('span.react-loading-skeleton');
if (!el3) {
..
}
}
Если таких элементов не было найдено — считаем что все компоненты загрузились, производим наши манипуляции с данными и останавливаем фоновую функцию:
if (!el3) {
let el = document.querySelector('#home > div.container > div.text-center > h1.display-1');
el.innerHTML = "Да здравствует хардкор!";
el.style.display='block';
clearInterval(intervalID);
}
Поскольку между рендером оригинала и нашими изменениями есть видимая задержка — изменяемый текст будет «моргать»:
сначала будет отображен оригинал, который через какое-то время изменится на нашу версию.
И пользователи это увидят и обязательно огорчатся. Чтобы их не смущать, необходимо спрятать изменяемый блок через CSS:
#home > div.container > div.text-center > h1.display-1 {
display: none;
}
а затем (после изменения DOM-дерева) его отобразить:
el.innerHTML = "Да здраствует хардкор!";
el.style.display='block';
В таком же точно стиле работает добавление новых элементов и даже целых новых блоков:
// выбор элемента по XPath выражению
let el2 = document.querySelector('#projects > div.container > div.container > div.row');
// вставляемый шаблон, в виде строки
let tpl = `<div class="col-md-6">
<div class="card shadow-lg p-3 mb-5 bg-white rounded card">
<div class="card-body">
<h5 class="card-title">кокой-то проект</h5>
<div class="d-grid gap-2 d-md-block"><a
href="https://github.com/hashirshoaeb/sometime-next-no-pwa/archive/master.zip"
class="btn btn-outline-secondary mx-2">
<i class="fab fa-github"></i>Клонировать</a>
<a href="https://github.com/hashirshoaeb/sometime-next-no-pwa" target=" _blank"
class="btn btn-outline-secondary mx-2">
<i class="fab fa-github"></i> Репозиторий</a>
</div>
<hr>
<div class="pb-3">Языки:
<a class="card-link" href="https://github.com/hashirshoaeb/sometime-next-no-pwa/search?l=JavaScript"
target=" _blank" rel="noopener noreferrer">
<span class="badge bg-light text-dark">JavaScript: 142.8 %</span>
</a>
<a class="card-link" href="https://github.com/hashirshoaeb/sometime-next-no-pwa/search?l=CSS" target=" _blank"
rel="noopener noreferrer">
<span class="badge bg-light text-dark">CSS: 27.1 %</span>
</a>
</div>
<p class="card-text">
<a href="https://github.com/hashirshoaeb/sometime-next-no-pwa/stargazers" target=" _blank"
class="text-dark text-decoration-none">
<span class="text-dark card-link mr-4">
<i class="fab fa-github"></i>
Звезды <span class="badge badge-dark">0</span>
</span>
</a>
<small class="text-muted">Обновлен 22,2024</small>
</p>
</div>
</div>
</div>`;
// сама вставка
el2.insertAdjacentHTML('beforeend', tpl);
Результат:
![](https://habrastorage.org/getpro/habr/upload_files/786/c17/944/786c17944fb441c26fc056d37e7b9e67.png)
Причем на вставленный таким образом блок будут распространяться и все эффекты оригинальных элементов:
тень и «подпрыгивание» блока при наведении мыши.
Ну что ж, с этой задачей разобрались, самое время повысить накал жести.
Задача третья: "поправить валидацию формы"
Разумеется есть и нормальные способы организовать взаимодействие с React-приложением в обе стороны — из стороннего JavaScript-кода вызывать React-компонент и из такого компонента вызывать сторонний JavaScript-код.
Но к сожалению все они требуют внутренних изменений в приложении, вроде регистрации компонента в контексте window, которые очевидно никто для вас вносить не станет.
Поэтому стандартные способы, о которых вы при желании сможете прочитать в документации и других статьях — для наших специфических задач к сожалению не применимы.
Поэтому будем применять нестандартные, как обычно.
Для лучшей иллюстрации я добавил в проект простую форму с логикой валидации:
![Оригинал вот тут, если кому интересно. Оригинал вот тут, если кому интересно.](https://habrastorage.org/getpro/habr/upload_files/7b2/42f/500/7b242f500c0669a44d47d96f258c76a0.png)
Тестовая форма
Визуально это что-то вроде формы регистрации, ключевой компонент React с реализацией формы был немного изменен по сравнению с оригиналом, выглядит так:
import { FC } from 'react';
import { useForm } from '../hooks/useForm';
import './Registration.scss';
import {
Container,
} from "react-bootstrap";
type Gender = 'МУЖ' | 'ЖЕН' | 'РОБОТ';
interface User {
name: string;
age: number;
email: string;
gender: Gender;
password: string;
}
const Registration: FC = () => {
const { handleSubmit, handleChange, data: user, errors } = useForm<User>({
validations: {
name: {
pattern: {
value: '^[A-Za-z]*#x27;,
message:
"You're not allowed to use special characters or numbers in your name.",
},
},
age: {
custom: {
isValid: (value) => parseInt(value, 10) > 17,
message: 'You have to be at least 18 years old.',
},
},
password: {
custom: {
isValid: (value) => value?.length > 6,
message: 'The password needs to be at least 6 characters long.',
},
},
},
onSubmit: () => alert('User submitted!'),
});
return (
<section className="section p-3">
<Container>
<form className="registration-wrapper" onSubmit={handleSubmit}>
<h1>Тестовая форма</h1>
<input
placeholder="ФИО*"
value={user.name || ''}
onChange={handleChange('name')}
required
/>
{errors.name && <p className="error">{errors.name}</p>}
<input
placeholder="Возраст"
type="number"
value={user.age || ''}
onChange={handleChange('age', (value) => parseInt(value, 10))}
/>
{errors.age && <p className="error">{errors.age}</p>}
<input
placeholder="Email*"
type="email"
value={user.email || ''}
onChange={handleChange('email')}
/>
<input
placeholder="Пароль*"
type="password"
value={user.password || ''}
onChange={handleChange('password')}
/>
{errors.password && <p className="error">{errors.password}</p>}
<select onChange={handleChange('gender')} required>
<option value="" disabled selected>
Пол*
</option>
<option value="male" selected={user.gender === 'МУЖ'}>
Мальчик
</option>
<option value="female" selected={user.gender === 'ЖЕН'}>
Девочка
</option>
<option value="non-binary" selected={user.gender === 'РОБОТ'}>
Боевая машина уничтожения
</option>
</select>
<button type="submit" className="submit">
Отправить
</button>
</form>
</Container>
</section>
);
};
export default Registration;
Помещен он был рядом с другими комонентами, с сохранением принципов их именования. Сами стили при этом не менялись и были взяты из оригинального проекта «как есть»:
.registration-wrapper {
flex: 1 0 100%;
display: flex;
flex-wrap: wrap;
margin: auto;
max-width: 600px;
padding: 30px;
border-radius: 5px;
border: 1px solid #16324f69;
background-color: #fff;
h1, input, select {
margin: 15px auto;
flex: 1 0 100%;
}
select {
opacity: 0.8;
height: 30px;
background-color: #16324f;
color: #fff;
padding: 5px 15px;
}
input, select {
border: none;
outline: none;
}
input {
padding-bottom: 5px;
border-bottom: 1px solid rgba(22, 50, 79, 0.41);
}
input:focus {
border-bottom: 1px solid rgb(22, 50, 79);
}
.submit {
cursor: pointer;
outline: none;
flex: 0 0 170px;
margin: 15px auto;
background-color: #16324f;
color: #fff;
border: none;
height: 30px;
border-radius: 5px;
font-size: 15px;
}
.error {
width: 100%;
text-align: left;
font-size: 12px;
color: #db222a;
}
}
Подключается компонент в файле App.js следующим образом (фрагмент):
const Home = React.forwardRef((props, ref) => {
return (
<>
<MainBody
gradient={mainBody.gradientColors}
title={`${mainBody.firstName} ${mainBody.middleName} ${mainBody.lastName}`}
message={mainBody.message}
icons={mainBody.icons}
ref={ref}
/>
{/* наш компонент с формой. */}
<Registration />
{about.show && (
<AboutMe
heading={about.heading}
message={about.message}
link={about.imageLink}
imgSize={about.imageSize}
resume={about.resume}
/>
)}
..
Эта реализация компонента с формой не использует каких-либо сторонних библиотек для валидации формы — только один чистый React.
Поэтому необходимо добавить еще обработчик формы (файл hooks/useForm.ts):
import { ChangeEvent, FormEvent, useState } from 'react';
interface Validation {
required?: {
value: boolean;
message: string;
};
pattern?: {
value: string;
message: string;
};
custom?: {
isValid: (value: string) => boolean;
message: string;
};
}
type ErrorRecord<T> = Partial<Record<keyof T, string>>;
type Validations<T extends {}> = Partial<Record<keyof T, Validation>>;
export const useForm = <T extends Record<keyof T, any> = {}>(options?: {
validations?: Validations<T>;
initialValues?: Partial<T>;
onSubmit?: () => void;
}) => {
const [data, setData] = useState<T>((options?.initialValues || {}) as T);
const [errors, setErrors] = useState<ErrorRecord<T>>({});
// Needs to extend unknown so we can add a generic to an arrow function
const handleChange = <S extends unknown>(
key: keyof T,
sanitizeFn?: (value: string) => S
) => (e: ChangeEvent<HTMLInputElement & HTMLSelectElement>) => {
const value = sanitizeFn ? sanitizeFn(e.target.value) : e.target.value;
setData({
...data,
[key]: value,
});
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const validations = options?.validations;
if (validations) {
let valid = true;
const newErrors: ErrorRecord<T> = {};
for (const key in validations) {
const value = data[key];
const validation = validations[key];
if (validation?.required?.value && !value) {
valid = false;
newErrors[key] = validation?.required?.message;
}
const pattern = validation?.pattern;
if (pattern?.value && !RegExp(pattern.value).test(value)) {
valid = false;
newErrors[key] = pattern.message;
}
const custom = validation?.custom;
if (custom?.isValid && !custom.isValid(value)) {
valid = false;
newErrors[key] = custom.message;
}
}
if (!valid) {
setErrors(newErrors);
return;
}
}
setErrors({});
if (options?.onSubmit) {
options.onSubmit();
}
};
return {
data,
handleChange,
handleSubmit,
errors,
};
};
Реализация обработчика не менялась, оригинал можете посмотреть вот тут.
Обратите внимание на расширение файла — это Typescript, для обработки которого необходимо добавить файл tsconfig.json с вот таким содержимым:
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}
В итоге получается такая форма регистрации, с несколькими типами валицидации вводимых данных:
![Самая обычная форма на React Самая обычная форма на React](https://habrastorage.org/getpro/habr/upload_files/b36/a5a/e95/b36a5ae9592e706393c1a43cc05bed56.png)
Тут есть как стандартные обязательные поля HTML-формы, с атрибутом «required»:
![](https://habrastorage.org/getpro/habr/upload_files/b97/541/1eb/b975411ebea1f349efaa9fab3dbe04b4.png)
Так и сложная валидация, реализованная внутри React-компонента:
![](https://habrastorage.org/getpro/habr/upload_files/2a5/65d/b28/2a565db28f7f3897c486505bbace828c.png)
После правильного заполнения срабатывает заглушка, вместо которой в реальном проекте будет взаимодействие с бекэндом:
![](https://habrastorage.org/getpro/habr/upload_files/4b6/dae/518/4b6dae518d0da63ef79dfb64e7bf59f8.png)
Изменяя обработку
Вот теперь имея такую форму, представьте что вам надо с ней что-то сделать, из типовых задач веб-разработки.
Но.. без исходников.
Не надо сразу бежать писать
жалобу в ООНзаявление на «увольнение по собственному желанию» и менять работу — есть менее радикальные варианты.
И начнем мы с простого:
отключим валидацию обязательности по признаку required.
Добавим в функцию makeWhenReady(), уже описанную выше вот такой простой код:
function makeWhenReady() {
let el3 = document.querySelector('span.react-loading-skeleton');
if (!el3) {
...
let elInput = document.querySelector('form.registration-wrapper > input');
elInput.required = false;
}
}
И.. этого достаточно для пропуска подобной валидации.
Описанное выше скорее всего уже закроет все ваши проблемы с бинарными бандлами.
Но что же делать с логикой реализованной в компонентах React, еще и обфрусцированной?
Не поверите, но и тут есть варианты.
Допустим, надо перехватить обработчик формы и вставить свой код в место отправки формы, отключив обработчики из React.
К сожалению в браузерах нет стандартного API для получения всех обработчиков у DOM-элемента, поэтому такое API придется реализовать.
Подробнее про эту проблему можно прочитать например вот тут.
Нам нужно переопределить метод addEventListener своей реализацией, причем сделать это до того как начнет работать React.
Чтобы этого добиться, вставляем свой блок <script></script> до места подключения бандла с React:
<!-- наш код вставки -->
<script>
...
</script>
<script defer="defer" src="/static/js/main.9fb48d54.js"></script>
Код переопределения функции addEventListener выглядит вот так:
EventTarget.prototype._addEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (a, b, c) {
if (c == undefined) c = false;
if (a == 'submit') {
console.log('hijack listener ', a);
if (!this.eventListenerList) this.eventListenerList = {};
if (!this.eventListenerList[a]) this.eventListenerList[a] = [];
this.eventListenerList[a].push({ listener: b, options: c });
}
this._addEventListener(a, b, c);
};
EventTarget.prototype._getEventListeners = function (a) {
if (!this.eventListenerList) this.eventListenerList = {};
if (a == undefined) { return this.eventListenerList; }
return this.eventListenerList[a];
};
В результате его работы, вы увидите в консоли браузера все регистрации обработчиков на отправку формы:
![](https://habrastorage.org/getpro/habr/upload_files/0cb/bb4/c6d/0cbbb4c6d9f757052261a293d311876a.png)
Но главное что у всех DOM-элементов, в которые происходили добавления обработчиков появится новая функция _getEventListeners(), которая будет содержать ссылки на все обработчики.
Зачем это надо?
Например для того чтобы эти обработчики было можно легко удалить:
let r = document.querySelector('#root');
console.log('root element:', r);
let rEvents = r._getEventListeners();
console.log('events:', rEvents);
for (let evt of Object.keys(dvevents)) {
console.log('evt:',evt);
for (let i = 0; i < dvevents[evt].length; i++) {
dv.removeEventListener(evt,dvevents[evt][i].listener);
}
}
Код выше необходимо вставить все в ту же функцию makeWhenReady(), которая как вы помните вызывается после полной инициализации React-приложения.
Пару слов про обработчики в React.
Оказалось что все обработчики React регистрируются в родительском DOM-элементе, внутри которого происходит отрисовка всех компонентов React:
<body><noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
Очевидно что при таком подходе обработчиков там будет очень много — имейте это ввиду.
Наконец для того чтобы добавить наш собственный обработчик для отправки формы, вставляем вот такой код (все также в функцию makeWhenReady()):
let elForm = document.querySelector('form.registration-wrapper');
elForm.addEventListener("submit", function (e) {
e.preventDefault();
alert('Hi there!');
});
Результат:
![Вместо отправки на сервер вызывается наш обработчик. Вместо отправки на сервер вызывается наш обработчик.](https://habrastorage.org/getpro/habr/upload_files/6d5/1d5/29e/6d51d529ed7bb6d3fbfb305fb5d9d882.png)
Эпилог
В общем случае действительно стоит отказаться от попыток доработки обфрусцированных бандлов — это «путь в никуда», решение которое невозможно поддерживать долго и рано или поздно оно все равно сломается, похоронив под собой и все ваши доработки.
Только в исключительных случаях, когда действительно горит и надо «поправить вчера» на такое имеет смысл идти.
Под давлением внешних обстоятельств, так сказать.
P.S.
Это немного отцезурированная версия статьи, чуть более трешевый оригинал которой доступен в нашем блоге.
0x08 Software
Мы небольшая команда ветеранов ИТ‑индустрии, создаем и дорабатываем самое разнообразное программное обеспечение, наш софт автоматизирует бизнес‑процессы на трех континентах, в самых разных отраслях и условиях.
Оживляем давно умершее, чиним никогда не работавшее и создаем невозможное — затем рассказываем об этом в своих статьях.
Комментарии (4)
zoto_ff
30.08.2024 00:27статья хорошая, но обфускация (пишется без "р") у вас ничего общего с реальной обфускацией не имеет. такой код называется packed + minified
для конкретно обфускации и деобфускации используются следующие инструменты: obfuscator.io, deobfuscate.io и synchrony
ester132
Решения проблем в целом понятны, но для сложного приложения этот подход его слишком нагрузит, ту там одним таймером не отделаешься, а для лендингов - проще с нуля написать, единственный кейс который реально обосновывает эти костыли если исправление очень плевок(пример со скрытие пункта меню), а заказчик плати за это много
alex0x08 Автор
Видимо вы еще просто не осознали насколько широко успел распространиться React :)
На сегодняшний день наверное каждый второй создаваемый лендинг будет на реакте, все крупные сайты из этого списка:
https://www.nationalgeographic.org/
https://www.codecademy.com/
https://bbc.com/
https://www.nytimes.com/
https://www.pwc.com/
уже на реакте, включая отдельные спецпроекты, которых там очень и очень много.
Каждый спецпроект — чаще всего тот самый лендинг, ныне это все отдельные приложения на реакте.
А вот например аггрегатор AI‑стартапов, каждый второй сайт компании — на реакте.
Так что реакта за последние годы стало прям очень много.
zoto_ff
и это ужасно. реакту есть альтернативы получше: solidjs, vue, да хоть svelte.
хорошо еще, если для лендингов используется SSG, а не полностью CSR (мне очень нравится astro.build)