Tarayıcıda kod çalıştıran bir playground yaptım
KısacaKullanıcının kodunu sunucuda hiç çalıştırmadan JavaScript, Python, HTML ve CSS çalıştıran bir editör yaptım; üstüne PIN girişi, akıllı düzeltmeler ve ayda birkaç dolarlık bir yapay zekâ kod tamamlama koydum. Güvenlik modelini, hız sorununu ve yolda yaptığım hataları anlatıyorum.
İçindekiler
Bir süredir sitemde küçük ama gerçek bir şey olsun istiyordum: yazı okumaktan fazlasını yaptıran, ziyaretçinin elini klavyeye götürdüğü bir yer. Sonuç playground.musabustun.dev oldu. Bir kod editörü: JavaScript, Python, HTML ve CSS yazıyorsun, çalıştır diyorsun, çıktı ve süre karşında. Üstüne kısa bir PIN girişiyle açılan akıllı düzeltmeler ve yapay zekâ destekli kod tamamlama koydum.
Bu yazıda yaptıklarımı sırayla anlatıyorum, ama asıl odağım kararların nedenleri ve yolda yaptığım hatalar. Bu tür projelerde kod kısmı en kolay kısım. Zor olan, "başkasının yazdığı kodu çalıştıran bir sayfa"nın kendini nasıl koruyacağı, ilk açılışın neden yavaş olduğu ve bir yapay zekâ özelliğini cebini yakmadan nasıl sunacağın.
Önce kural: sunucuda kullanıcı kodu çalışmaz#
Kod çalıştıran bir web uygulamasının en tehlikeli hali, ziyaretçinin yazdığı kodu kendi sunucunda çalıştırmasıdır. Bir while True ile işlemciyi kilitleyebilir, dosya sistemine bakabilir, iç ağa istek atabilir. Bunu güvenle yapmak için container izolasyonu, kaynak sınırları, ağ kapatma ve zaman aşımı gibi bir dünya dolusu şey gerekir.
Ben bunların hiçbirini yapmamayı seçtim. Tasarımın ilk cümlesi şu: sunucu kullanıcı kodunu hiçbir zaman çalıştırmaz. Kod ziyaretçinin kendi tarayıcısında çalışır. Sunucunun işi yalnızca dosyaları sunmak, girişi doğrulamak ve (sonra anlatacağım) yapay zekâ isteklerine aracılık etmek.
Bu ilkeyi önce kendim çiğnedim. İlk sürümde Go da desteklemek istedim. Go'yu çalıştırmak için sunucuda derleme gerekiyordu: ayrı bir servis, ağı kapatılmış bir container, bir unix soketi. Bunu yerelde uçtan uca doğruladım, ama sonra fark ettim ki bu, yukarıdaki ilkenin tam tersiydi. Sonunda Go'yu bütünüyle kaldırdım, Python'u tuttum, HTML ve CSS ekledim. Büyük bir geri adımdı, ama sistem sadeleşti ve saldırı yüzeyi küçüldü.
Kod tarayıcıda nasıl güvenle çalışıyor#
"Tarayıcıda çalışır" tek başına güvenlik değil. Kullanıcı kodu aynı sayfada çalışırsa çerezlere, localStorage'a ve sayfanın kendisine erişir. Bu yüzden iki katman var.
1. İzole bir iframe. Kod, sandbox="allow-scripts" özniteliğiyle açılan bir iframe içinde çalışır. allow-same-origin bilerek verilmez, yani iframe opak bir origin'de yaşar: ana sayfanın çerezlerine, depolamasına ya da DOM'una dokunamaz. Kendi içinde bir Web Worker açar, kod orada koşar.
2. Kendi CSP'si olan belgeler. Sandbox sayfasının ve HTML/CSS önizleme sayfasının her birinin kendi Content-Security-Policy başlığı var. Önizleme için srcdoc kullanmadım, çünkü srcdoc belgesi üst sayfanın CSP'sini miras alır ve istediğim kısıtları koyamazsın. Bunun yerine önizleme ayrı bir adresten (/preview.html) sunuluyor.
Bir de paylaşım bağlantıları var. Birine kod bağlantısı gönderebiliyorsun, ama o bağlantı açıldığında kod kendiliğinden çalışmaz. Çalıştırmak için kullanıcının tuşa basması gerekir. Başkasının gönderdiği kodu farkında olmadan çalıştırmanı engelleyen küçük ama önemli bir kural.
Bunu "yeterince iyi" olarak görüyorum, ama kusursuz demiyorum. Tarayıcı kaynaklı bir sandbox açığı bunu aşabilir. Kullanıcı kodu kendi makinesinde çalıştığı için risk büyük ölçüde kullanıcının kendisine kalıyor. Ana sitenin oturum çerezi (mu_session) host-only olduğu için alt alan adına zaten gitmiyor. Bu özelliği bozmamak, alt alan adında kod çalıştıran her uygulamanın dayandığı bir sözleşme.
Python'u tarayıcıya sığdırmak#
JavaScript'i çalıştırmak kolay: bir Worker aç, kodu çalıştır. Python farklı. Tarayıcıda Python çalıştırmak için Pyodide kullanıyorum, yani CPython'un WebAssembly'ye derlenmiş hali. Çalışıyor, ama bedeli var: ana WASM dosyası ham halde yaklaşık 9,4 MB. Brotli ile sıkıştırınca yaklaşık 2,7 MB.
İlk sürümde kullanıcılar "Python yükleniyor" yazısında uzun süre kalıyordu. İki ayrı sorun vardı.
Yavaş indirme. Dosyaları kendi sunucumdan (self-host) veriyorum, harici CDN yok. Ama Cloudflare üzerinden gelen trafik Türkiye'den Amsterdam veri merkezine düşüyordu ve Amsterdam'dan sunucuma giden hat çok yavaştı: önbelleksiz isteklerde ilk bayt yaklaşık 2,4 saniye, büyük dosyalarda 20–75 KB/s. Aynı dosyayı sunucudan doğrudan istediğimde ilk bayt 0,15 saniye, hız saniyede birkaç megabayt. Yani sorun benim kodum ya da Pyodide değil, aradaki yoldu. playground alt alan adını Cloudflare proxy'sinden çıkarıp (DNS-only) doğrudan sunucuya baktırdım. Bunun bir bedeli var: Cloudflare'in önbelleği ve WAF'ı bu host için artık devrede değil. Korumayı uygulamanın kendisi yapıyor.
Yükleme deneyimi. Python hazır olana kadar bir ilerleme halkası gösteriyorum ve en önemlisi: editör Python'dan bağımsız açılıyor. Önce hafif bir metin alanı gelir, Monaco editörü arka planda yüklenip hazır olunca sessizce devralır. Kullanıcı yazmaya başlamışsa yazdığı kaybolmaz.
input() neden çalışmıyordu#
Bunu kendi kodumla yakaladım. Şu üç satırı çalıştırınca hata alıyordum:
print("merhaba")
isim = input("Adın ne?")
print(isim)Hata EOFError idi. Sebep basit ve ilginç: kod bir Web Worker içinde çalışıyor ve Python input() çağırdığında beklemek zorunda. Worker'ın klavyesi yok. Beklemenin tek yolu, Worker'ı bloklayıp ana sayfadan cevap gelmesini beklemek. Bunu da SharedArrayBuffer ve Atomics.wait ile yapabiliyorsun: Worker ortak bir bellek alanında uyur, ana sayfa kullanıcının yazdığı metni oraya yazıp uyandırır.
Ama SharedArrayBuffer tarayıcıda yalnızca "cross-origin isolated" sayfalarda var. Bunun için sunucunun iki başlık göndermesi gerekiyor: Cross-Origin-Opener-Policy: same-origin ve Cross-Origin-Embedder-Policy: require-corp. Başlıkları ekledim, kendi kaynaklarımı buna uygun hale getirdim (Cross-Origin-Resource-Policy). Sonuç: input("Adın ne?") yazılan satırın yanında bir giriş kutusu açıyor, Enter'a basınca kod devam ediyor.
Küçük bir dipnot: hatayı ilk gördüğümde şablon kodun bozuk olduğunu düşündüm. Meğer o, tarayıcının localStorage'ında kayıtlı eski bir taslakmış. Kayıtlı taslağın şablonu gölgelemesi, editörlerde sık rastlanan bir hata ayıklama tuzağı.
Giriş: bilerek basit, bilerek sınırlı#
Akıllı özellikleri herkese açmak istemedim, çünkü yapay zekâ çağrıları para harcıyor. Bu yüzden akıllı düzeltmeler ve AI kod tamamlama, 4 haneli bir demo PIN'in arkasında. Bunu açıkça "demo" diye konumlandırıyorum, çünkü 4 haneli bir PIN gerçek bir güvenlik önlemi değil, sadece bir kapı. Asıl koruma başka yerde:
- PIN sunucu tarafında doğrulanıyor, tarayıcıda hiçbir yerde yok.
- Oturum çerezi
HttpOnly,Secure,SameSite=Strictve host-only. - IP başına 10 dakikada 5 hatalı denemeden sonra kilit, ayrıca genel bir üst sınır.
- İstekler
Originve JSON içerik türü kontrolünden geçiyor (CSRF). - Giriş yapmamış bir ziyaretçi akıllı özelliklerin kodunu hiç indirmiyor. O kod ancak girişten sonra tembel (lazy) yükleniyor.
Yani biri PIN'i kaba kuvvetle bulsa bile kazanacağı şey sınırlı: günlük bir AI istek bütçesi ve akıllı düzeltmeler.
Akıllı düzeltmeler#
Girişten sonra editör diline göre akıllı oluyor. JavaScript ve TypeScript, HTML ve CSS için Monaco'nun yerleşik dil servisleri çalışıyor. Python için tarayıcıda çalışan Ruff (WebAssembly sürümü) hata ve uyarıları üretiyor. Kod biçimlendirme için Prettier, HTML'de kısaltma açma için Emmet var. Bunların hepsi sunucuya tek bir istek atmadan, tarayıcıda çalışıyor.
Yapay zekâ kod tamamlama: ayda birkaç dolara#
Asıl heyecan verici kısım burası: Cursor ya da Copilot'taki gibi, yazarken gri bir "hayalet metin" ile devamı öneriliyor, Tab ile kabul ediyorsun.
Burada üç şeyi aynı anda dengelemem gerekiyordu: kalite, gecikme ve maliyet. Üç seçeneği düşündüm: kendi sunucumda küçük bir model çalıştırmak, hazır bir API kullanmak ya da yalnızca kural tabanlı basit öneriler vermek.
Kendi sunucum 6 çekirdekli bir işlemci, GPU yok. Orada ancak yarım milyar parametreli bir model gerçekçi ve hedeflediğim 400–600 milisaniyelik gecikmeyi tutturamıyor. Bu yüzden hazır bir API'ye karar verdim: Mistral'in kod için eğittiği Codestral modeli, "fill-in-the-middle" (FIM) yöntemiyle. FIM'in avantajı şu: modele yalnızca imlecin öncesini değil, sonrasını da veriyorsun. Böylece fonksiyonun ortasında yazarken bile ortadaki boşluğu doğru dolduruyor.
Canlıda bir denemede şu kodu yazarken:
def toplam(a, b):
return a + b
def carp(a, b):
model imleç noktasına return a * b önerdi, yaklaşık yarım ile bir saniye arasında. Tek bir deneme, ama FIM'in neden işe yaradığını gösteriyor: fonksiyonun adından (carp) ve aşağıdaki print(carp(2, 3)) çağrısından ne yazılması gerektiğini çıkarıyor.
Maliyeti kontrol altında tutmak#
Bir yapay zekâ özelliğinin en kolay hatası, her tuş vuruşunda istek atmak. Ben bunu şöyle önledim:
- Küçük bağlam. Modele yalnızca imlecin öncesindeki son 1200 karakteri ve sonrasındaki ilk 400 karakteri gönderiyorum, dosyanın tamamını değil.
- Kısa çıktı. En fazla 48 token. Hayalet metin zaten kısa olmalı.
- Önbellek. Aynı bağlam için sonuç 5 dakika saklanıyor. Geri alıp yeniden yazınca yeni istek atılmıyor.
- Akıllı atlama. Boş dosyada, tek harf yazılmışken, seçim varken, sekme arka plandayken, çok büyük dosyada istek atılmıyor. Kullanıcı önerinin başını yazmaya devam ederse yeni istek atmak yerine önceki önerinin kalanını gösteriyorum.
- Debounce ve iptal. Yazmayı bıraktıktan 350 milisaniye sonra istek atılıyor, yazmaya devam edilirse uçuştaki istek iptal ediliyor.
- Üst sınırlar. Günde en fazla 300 gerçek API isteği, ayrıca oturum başına dakikalık bir sınır. Sınır dolunca yapay zekâ o oturum için kapanıyor ve sessiz bir bilgi notu gösteriliyor.
- Sessiz geri çekilme. Mistral yavaşlar ya da hata verirse kullanıcı hata görmüyor, sistem yerleşik kural tabanlı önerilere düşüyor.
Kabaca bir istek yaklaşık 0,0002 dolar tutuyor. Günde 300 isteğin hepsi gitse bile aylık birkaç dolar eder, yani Mistral'in aylık ücretsiz kredisine sığması beklenir. Bu bir tahmin, ölçüm değil. Gerçek harcamayı birkaç hafta sonra panelden bakıp karşılaştıracağım.
API anahtarı yalnızca sunucuda, ortam değişkeninde duruyor. Tarayıcıya hiç gitmiyor: tarayıcı kendi sunucuma istek atıyor, o da Mistral'a iletiyor. İstek içeriği (yani kullanıcının kodu) loglanmıyor.
"AI ile düzelt" ve ucuz modelin sınırları#
Bir hata altı çizili olduğunda ampule ya da Ctrl+.'ye basınca "AI ile düzelt…" seçeneği çıkıyor. Bunu yalnızca kullanıcı tıklayınca çalıştırıyorum, kendiliğinden değil: hem maliyet hem de "kodum kendi kendine değişmesin" için. Bunun için en ucuz modellerden biri olan ministral-3b'yi seçtim. Model JSON biçiminde küçük bir düzenleme önerisi döndürüyor, kullanıcı kabul ederse tek bir geri alınabilir adımda uygulanıyor.
İlk canlı denemem ucuz modelin sınırını gösterdi. print("merhaba" satırındaki eksik kapanış parantezini düzeltmesini istedim. Model parantezi eklemek yerine açılış parantezini merhaba kelimesiyle değiştirdi, yani kodu bozdu. Sebep belirsiz bir istemdi: modele "bir aralığı değiştir" demiştim ama "eklemek için boş aralık kullan" demeyi unutmuştum. İstemi düzelttim: değiştirmenin ve eklemenin farkını açıkça anlatan kurallar ve küçük bir örnek ekledim. Düzeltmenin canlıda tuttuğunu üç farklı hata türünde (Python, JavaScript, CSS) ve iki ucuz modelle (ministral-3b, ministral-8b) karşılaştırmak istiyorum. Bu yazıyı yazarken bu karşılaştırmanın sonucu elimde yoktu. 3b yetmezse 8b'ye geçeceğim, o da hâlâ çok ucuz.
Buradan çıkan ders: ucuz modellerde kalite çoğu zaman modelden çok istemin netliğine bağlı. Ve bir yapay zekâ çıktısını doğrudan uygulamak yerine doğrulamak gerekiyor. Sunucu tarafında önerinin biçimini sıkı denetliyorum: en fazla 3 düzenleme, hepsi pencerenin içinde, toplamı 600 karakterin altında, birbiriyle çakışmayan. Geçersizse boş bir sonuç dönüyor ve kullanıcıya "öneri bulunamadı" deniyor.
Dağıtım: her push otomatik canlıya#
Uygulama tek bir Docker servisi olarak Coolify'da çalışıyor, Traefik arkasında, Let's Encrypt sertifikasıyla. Depoda main dalına her push, bir GitHub webhook'u üzerinden ilgili uygulamayı otomatik deploy ediyor. İzleme yolları (watch paths) sayesinde yalnızca ilgili klasör değişince ilgili uygulama yeniden kuruluyor. Sunucu tarafı bağımlılıksız bir Node ağ geçidi: statik dosyaları önceden brotli ve gzip ile sıkıştırıp servis ediyor.
Bu akışta iki küçük pürüz yaşadım ve ikisi de öğretici:
- İlk build düştü. Dockerfile'da paylaşılan TypeScript yapılandırma klasörünü kopyalamayı unutmuştum. Hata mesajı doğrudan söylüyordu (
tsconfigbulunamadı), ben de sorunu yerelde bire bir yeniden üretip düzelttim. - API anahtarının süresi doldu. Mistral anahtarını oluştururken bir bitiş tarihi koymuşum ve anahtar o gün öğleden sonra çalışmayı bıraktı. Kullanıcı hata görmedi, çünkü sistem sessizce kural tabanlı önerilere düştü. Bunu ancak elle bir sağlık kontrolü yaparken fark ettim. Sessiz geri çekilme kullanıcı için harika, ama sana bir şeyin bozulduğunu söylemiyor. Sıradaki iş, bu tür bir hatada bir uyarı kaydı ya da basit bir izleme eklemek.
Ana sitedeki yeri#
Playground ayrı bir alt alan adı, ama ana siteyle bütün gibi hissettirmesini istedim. Ana sayfada bir bölüm var: iki kelimelik bir başlık, bir cümlelik açıklama ve sahte ama dürüst bir editör maketi. Alt bilgide, komut paletinde (⌘K) ve g ardından p kısayolunda da yer alıyor. Ana siteden Playground'a geçerken Swup'taki gibi bir çıkış animasyonu, geri dönerken giriş animasyonu oynuyor. İki ayrı origin arasında bile tek bir site gibi geçiş yapıyorsun.
Neleri doğrulamadım#
Dürüst olmak gerekirse bazı şeyleri henüz yapmadım:
- Firefox ve Safari'de test etmedim, çoğu şeyi Chrome'da denedim.
- Gerçek bir klavyeyle PIN girişini otomasyon ortamında güvenilir biçimde deneyemedim. Sunucu tarafını ve çerezi komut satırından doğruladım.
- Kod tamamlamanın günlük gerçek maliyeti bir tahmin. Ay sonunda faturaya bakacağım.
ministral-3b'nin düzeltme kalitesi yeterli mi, henüz kesin değil.
Sonuç: ne öğrendim#
- En iyi güvenlik, çalıştırmamaktır. Sunucuda kullanıcı kodunu çalıştırmamak, onlarca savunma katmanından daha ucuz ve daha sağlam.
- Yavaşlığı tahmin etme, ölç. "Python yavaş" demek kolaydı. Asıl sorun Pyodide değil, bir ağ yoluydu. Ölçmeden bunu bulamazdım.
- Yapay zekâ özelliğinin maliyeti bir tasarım meselesi. Bağlamı küçült, çıktıyı kısalt, önbellekle, gereksiz isteği atma, bir tavan koy.
- Ucuz model, net istem ister. Çıktısına körü körüne güvenme: biçimini doğrula, kullanıcıya göster, geri alınabilir yap.
- Sessiz geri çekilme iki yüzlü. Kullanıcıyı korur ama seni kör eder.
Playground'u denemek istersen: playground.musabustun.dev. Kodu çalıştırmak için giriş gerekmiyor, akıllı özellikler için demo PIN gerekiyor. Önerin ya da bulduğun bir hata olursa bana yaz.