🌐

C#でJavaScriptEngine+ブラウザ自作した

に公開1

はじめに

C# で JavaScript エンジンを書いていたら、最終的に簡単な Web サイトを表示して JavaScript も少し動くブラウザのようなものができました。

WebView2 や Chromium Embedded Framework を使ったものではありません。JavaScript エンジン、DOM と JavaScript の接続、HTML/CSS から描画データへの変換、レイアウト、入力、HTTP リクエストまわりをほぼ C# で実装しています。

完全に全てを自作しているわけではありません。HTML パースの一部には AngleSharp、描画には SkiaSharp、ウィンドウや入力には Silk.NET、通信には HttpClient を使っています。ただし「ブラウザとしてどう動くか」の部分、つまり JavaScript の値表現から DOM binding、CSS を解釈して native window に描くまでの経路は自前です。

  • ブラウザ/UI/HTML/CSS/rendering:

https://github.com/akeit0/Enaga

  • JavaScript engine/runtime:

https://github.com/akeit0/okojo

Webについては元々あまり詳しくないので、問題などがあればコメントなどで教えて貰えると嬉しいです。

実装タイムライン

最初からブラウザを作るつもりではありませんでした。記録を見返すと、だいたいこういう順番です。

  • 2026/2/3: C# で native ライブラリなしに JavaScript をゲームなどへ組み込みたいと思う。Jint はレガシーな実装で allocation が多いのが気になり、QuickJS/mQuickJS の移植を考える。
  • 2026/2/7: JavaScript engine の実装を始める。
  • 2026/2/8: prototype として ES5 の多くが動くようになり、ES6 を target にし始める。
  • 2026/2/13: REPL が動く。

https://x.com/Akeit0_/status/2022139782361673832

  • 2026/2/24: async / await が動く。
  • 2026/2/25: 負債がたまりすぎたので、それまでの実装を捨てて V8 Ignition を参考に書き直す。V8 の document を読み、Node の --print-bytecode と比較する tool も作った。direct evalwith は切った。
  • 2026/3/16: ES2025 までの test262 が 90% 以上通る。
  • 2026/3/25: RegExp を C# の Regex ではなく ECMAScript に寄せて実装し始める。
  • 2026/3/27: RegExp がだいたい動く。
  • 2026/4/1: Node runtime を実装し始める。Wasmtime も組み込む。
  • 2026/4/3: ink、つまり React + react reconciler が動く。

https://x.com/Akeit0_/status/2039878712523641205

  • 2026/4/11: JavaScript engine の Okojo を公開。

https://x.com/Akeit0_/status/2042902335287103775

  • 2026/4/18: react reconciler で C# GUI を描画できないか試す。

https://x.com/Akeit0_/status/2045430196053192886

  • 2026/4/28: HTML/CSS もできそうなので試す。

https://x.com/akeit0_/status/2049069528903250216

  • 2026/5/1: ここまでの成果物でブラウザができそうなので実装し始める。

https://x.com/Akeit0_/status/2050062826023301201

  • 2026/5/2: 少し整えて UI + browser project として Enaga を公開。

https://x.com/Akeit0_/status/2050500916776993243

最初は JavaScript engine でした。そこに Node っぽい runtime、React renderer、SkiaSharp の描画層、HTML/CSS が順に足されて、結果としてブラウザになりました。

この順番は後になってみればけっこう大事だったかもしれません。最初から「ブラウザを作る」と考えていたら範囲が広すぎます。先に JavaScript の値、object、realm、agent、module、worker などがあり、その上に window/document を載せられる状態になっていたので、ブラウザ側は host API と rendering をつなぐ問題として扱えました。

最初の 1 リクエストから描画まで

URL を入力してから最初の画面が出るまでの流れは、おおよそ次のようになります。

URL

HTTP GET

byte[] と response headers

charset 判定して HTML text にする

HTML parse

linked stylesheet を追加 request

DOM + author CSS

CSS cascade / computed style

scene node

layout

SceneLayoutCommit

SkiaSharp で native surface に paint

最初から「DOM を描く」わけではありません。HTML と CSS から一度 Enaga の scene/layout 表現に落として、その commit を SkiaSharp の SKCanvas に描いています。

以降はこの流れを、ページロード、JavaScript runtime、DOM binding、HTML/CSS rendering、input の順に分けて見ていきます。

ページロードと HTTP

HTTP request で何を送っているか

HTTP は SocketsHttpHandler + HttpClient です。

ページ読み込みは、JavaScript runtime と分けています。HtmlBrowserScriptRuntime は JavaScript execution、DOM binding、timer、event dispatch を見る側で、document をどう fetch するかは HtmlBrowserDocumentLoader 側に寄せています。

メイン document の loader は、だいたい次のような設定の HttpClient を使います。

AllowAutoRedirect = true
AutomaticDecompression = GZip | Deflate | Brotli
CookieContainer = new CookieContainer()
UseCookies = true

request header は普通の API client ではなく、ブラウザの document navigation に寄せています。

User-Agent: Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Enaga.Html.Loader/1.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: ja-JP,en-US;q=0.9,en;q=0.8 など
Referer: 直前の document URL がある場合

User-Agent が browser-shaped なのは、Chrome を名乗りたいからではなく、サーバー側の UA sniffing で unknown client 扱いされるのを避けるためです。Mozilla/5.0 ... Safari/537.36 のような形にしておかないと、実際のサイトでは別の HTML が返ったり、bot 向けのページに落ちたりします。

一方で Enaga.Html.Loader/1.0Enaga.Browser/1.0 の token は残しています。ログ上は Enaga から来た request だと分かるようにするためです。

script / fetch / worker module では別の browser request profile を使い、Sec-Fetch-DestSec-Fetch-ModeSec-Fetch-Site も足しています。

Sec-Fetch-Dest: script / empty など
Sec-Fetch-Mode: no-cors など
Sec-Fetch-Site: same-origin / cross-site 相当の値

ただし、ここはまだ「ブラウザっぽい request を作る」段階です。CORS、CSP、Referrer-Policy、SameSite、storage partitioning まで含めた browser network stack ではありません。

実装上は BrowserRequestProfileUser-AgentAccept-Language の default を持ち、BrowserHttpRequestFactory が request に header を載せます。HtmlBrowserScriptRuntime 自体に header 文字列を直書きしないようにして、JS runtime/event loop runner と HTTP request profile を分けています。

また、すべての通信が 1 つの browser network stack に集約されているわけではありません。

  • document / stylesheet
  • external script / fetch
  • worker module
  • image
  • font

は別の HttpClient や cache を使う経路があります。実装は分かりやすいですが、cookie jar や policy が完全に共有されるわけではありません。これはまだブラウザ互換というより、実サイトを読み込むための段階です。

response はどう処理しているか

document request の response は、まず byte[] として読みます。

そこから charset を決めます。優先順はかなり実用寄りで、

  1. HTTP Content-Typecharset
  2. BOM
  3. HTML 内の charset 宣言
  4. UTF-8 fallback

のように見ています。

ここで HTML text が得られると、次に HTML を parse して document の base URL を決めます。HTTP redirect があれば最終 URL を base にします。local file の場合は file path の directory が base になります。

cookie gate のようなページ向けに、Set-Cookie を受け取ったあと同一 document の gate link を 1 回だけ追う処理も入れています。これは navigation algorithm ではなく、実サイトでよくある「cookie をセットしてから同じページに戻す」形を通すための狭い workaround です。

そのあと <link rel="stylesheet"> を拾って、base から relative URL を解決して追加 request します。

GET /index.html

HTML text

<link rel="stylesheet" href="/assets/site.css">

GET /assets/site.css

HTML text + CSS text

この段階で renderer に渡すものは、単なる HTML 文字列ではなく、

  • HTML text
  • linked stylesheet を結合した CSS text
  • base path / base URL

を持った document になります。

外部 script も同じように document base から解決します。ただし script は document loader ではなく browser runtime 側で読み、classic script として Okojo に渡します。

<script src="./app.js">

base URL から解決

script 用 Accept header で GET

JavaScript text

fetch() の response は JavaScript に見える Response object へ変換します。okstatusstatusTexturlheaders.get()headers.has()text()json()arrayBuffer() など、実サイトでよく触る面から実装しています。

JavaScript エンジン Okojo

ブラウザ部分の前に、JavaScript エンジンがあります。Okojo は C# 製の JavaScript エンジンで、ECMA-262 の実行モデルに寄せて実装しています。

ブラウザに関係する最小の単位はこのあたりです。

ECMA-262 の概念 実装上の対応
ECMAScript value JsValue
Object JsObject
Realm JsRealm
Agent JsAgent
Execution Context VM の call frame
host 側の所有者 JsRuntime

JsRuntime は仕様上の概念というより embedding の入口です。JsRuntime が main JsAgent を持ち、その agent が main JsRealm を持ちます。

JsValue の内部表現

JsValue は JavaScript の値そのものです。ここを雑に作ると、演算、プロパティアクセス、関数呼び出し、DOM binding、fetch の戻り値まですべて重くなります。

Okojo の JsValuereadonly struct で、StructLayout(LayoutKind.Explicit) を使っています。

簡略化するとこういう形です。

[StructLayout(LayoutKind.Explicit)]
public readonly struct JsValue
{
    [FieldOffset(0)] public readonly ulong U;
    [FieldOffset(8)] public readonly object? Obj;
}

U はタグと payload を持ちます。Obj は string / object / symbol / bigint など、managed reference が必要な値の実体を持ちます。

タグ判定には NaN-boxing に近い形を使っています。

internal const ulong BoxHdr = 0x7FF8000000000000UL;
internal const ulong BoxMask = 0xFFFF000000000000UL;
internal const int TagShift = 44;

U & BoxMaskBoxHdr でなければ、その値は Float64 とみなします。

public bool IsFloat64 => (U & BoxMask) != BoxHdr;

一方、nullundefinedboolint32stringobject などは BoxHdr に tag を詰めた値になります。

Float64:
  U の上位が BoxHdr ではない

Tagged value:
  U = BoxHdr | (tag << 44) | payload

undefinednullU だけで表せます。

undefined = BoxHdr | (JsTagUndefined << 44)
null      = BoxHdr | (JsTagNull << 44)
false     = BoxHdr | (JsTagBool << 44)
true      = BoxHdr | (JsTagBool << 44) | 1

int32 は tag と payload に整数値を入れます。doubleint32 を分けているので、小さい整数演算で毎回 boxed object を作る必要がありません。

stringobject は、U 側には JsTagString / JsTagObject を入れて、Obj 側に実体の参照を入れます。

string:
  U   = BoxHdr | (JsTagString << 44)
  Obj = string-like object

object:
  U   = BoxHdr | (JsTagObject << 44)
  Obj = JsObject

これは C/C++ のエンジンのように pointer をそのまま NaN-boxing しているわけではありません。C# の managed object reference は Obj フィールドに持ち、U は型判定と primitive payload に使っています。

16byteと大きいですが、C#で安全に表現するにはこの形が最善だと思っています。

JsObject と shape

JavaScript の object は JsObject です。

named property は shape と slot で管理しています。固定的なプロパティ構造なら shape transition で slot 配置を共有し、delete や redefine が多い場合は dictionary 的な表現へ落とします。
これはshape (v8ではmap)と呼ばれるjsengineでよく用いられる構造です。

RealmAgent

JsRealm は intrinsics と global object の単位です。Object.prototypeArray.prototypePromiseSharedArrayBufferAtomics などは realm にインストールされます。

ブラウザ runtime はこの realm の global に windowdocumentlocationnavigatorfetchsetTimeout などを追加します。

JsAgent は job queue や module graph を持つ実行主体です。ECMA-262 の Agent に合わせて、worker を作る場合は worker 用の agent が作られます。これは worker のために特別な説明を足したというより、ECMA-262 を実装していくと自然に必要になる単位です。

ブラウザ側では timers、network completion、message などを host task として queue に積み、1 turn ごとに host task を進めてから microtask を drain します。

JavaScript と browser runtime

DOM binding

JavaScript から見える DOM は、C# 側 DOM の薄い wrapper です。

例えば document.getElementById("result") は、C# 側の DOM index から element を探し、その element の node id に対応する JS object を返します。

同じ node に対してはできるだけ同じ wrapper object を返します。これは el === document.getElementById(...) のようなコードを考えると必要になります。

textContent の setter はだいたい次のような流れです。

JS: element.textContent = "updated"

setter host function

HtmlDomDocument.SetTextContent(nodeId, text)

DOM model に反映

renderer へ通知

同じ DOM node id を使って layout / fragment damage を解決

DOM mutation は HTML 文字列の置換として扱っていません。C# 側の DOM model に反映し、その DOM node id を基準に renderer 側の layout / fragment / hit-test へ伝えます。

ここで大事なのは、JavaScript から見える DOM と、renderer が見る DOM の identity をそろえることです。textContentsetAttribute で element の中身が変わっても、同じ node が生きているなら同じ HtmlNodeId のまま扱います。renderer はその id を使い、必要な layout subtree、fragment、hit-test を dirty にします。

まだ DOM tree と layout tree が完全に一対一で対応するわけではありません。inline formatting や anonymous box 的なものは scene 側で作られます。それでも、DOM node id と layout version を持っていると、「ページ全体を読み直す」のではなく、影響した subtree と ancestor を中心に layout / paint の dirty range を扱えます。

innerHTML も単純な文字列結合ではなく、fragment parse して子ノードを置き換えます。これは実サイト由来の HTML ではすぐ必要になります。

JS wrapper object も DOM node id で cache しています。同じ node id が生き残る限りは、document.getElementById(...) で何度取っても同じ wrapper を返せます。innerHTML などで node が import/replacement された場合は新しい node id になるので、新しい wrapper になります。

script 実行

HTML から見つかった classic script は、document order で読み、Okojo で実行します。

ここでは full browser の script algorithm までは実装していません。asyncdefer、module script、preload、parser blocking の細かい相互作用はかなり複雑です。classic script path は、document から見つかった script を source order で読み、順に実行する形です。

browser の main realm では window === globalThis になります。Okojo の GlobalObject を browser の window として使い、windowselfglobalThistopparent が同じ object を指すようにしています。

そのため window.foo = ... は global object への property write になり、後続 script の bare identifier foo からも見えます。fetch や timer などは Okojo の generic web runtime が先に global binding として入れる場合があるので、browser runtime 側で global binding も明示的に上書きします。単に window object に同名 property を足すだけだと、generic fetch が優先されるようなズレが出ます。

javascript: URL は URL 解決の段階で特別扱いします。mailto:tel:、fragment も同じで、普通の relative URL として document fetch に流してはいけません。クリックや location.href、form GET submit も runtime 内で即 document replacement するのではなく、host に navigation intent として渡します。history や window shell は reusable browser runtime ではなく、SampleBrowser のような host 側の責務にしています。

timer も browser runtime 側で setTimeout / setInterval を install しています。generic な web runtime global に任せるのではなく、browser host scheduler と repaint/update の順序に合わせるためです。clearInterval 後に callback が reschedule されないように、interval の active state も delayed operation とは別に持っています。

Worker

new Worker("./worker.js")

document base から worker URL を解決

BrowserWorkerModuleLoader で source を読む

worker 用 agent / realm で実行

worker script の解決は browser document 基準です。local file なら document path / base path から、HTTP(S) なら document URL / base URL から解決します。

new Worker("./worker.js") は classic worker として script text を読み、new Worker("./worker.js", { type: "module" }) は module worker として module loader に流します。classic worker では importScripts(...) も同じ worker script loader を通して同期的に読み込み、実行中 worker script を referrer として相対 URL を解決します。

まだ worker-origin policy、structured clone の完全実装、lifecycle/event model、Service Worker まであるわけではありません。ただ、dedicated worker の selfonmessagepostMessageimportScripts、module import、SharedArrayBuffer/Atomics が同じ agent/realm の仕組みの上で動く形にはなっています。

cookie は CookieContainer による transport-level の対応です。

Set-Cookie が返れば HttpClient の handler が cookie を保持し、同じ client からの次の request に Cookie header を付けます。document loader には、cookie gate のようなページに対して 1 回だけ同一 document link を追う処理も入っています。

ただし、これはブラウザの cookie subsystem ではありません。

  • document.cookie は未実装
  • document loader と script/fetch で cookie jar が分かれている
  • 永続 cookie はない
  • user-visible な browser profile もない
  • SameSite や third-party cookie policy を自前で実装しているわけではない

storage は localStoragesessionStorage の同期 API を実装しています。

ただしこちらも現状は in-memory です。

  • sessionStorage は runtime 単位
  • localStorage は process 内で origin ごとに共有
  • quota なし
  • 永続化なし
  • storage event なし

JavaScript 側に見えている storage object も、proxy 的な完全実装ではありません。lengthkey(index)getItemsetItemremoveItemclear を持つ明示的な API surface です。localStorage.foo = "x" のような direct property write は backing storage へ反映しません。

IndexedDB、Cache API、Origin Private File System、quota management などはまだありません。

HTML/CSS から native window へ

HTML/CSS から layout へ

HTML を parse したあとは CSS を解決します。

ここでは selector matching、cascade、computed style への変換を行い、DOM node ごとに layout に使う値へ落とします。

対応している CSS は当然まだ一部ですが、単純なサイトを表示するには次のようなものが効いてきます。

  • display: block / inline / inline-block / flex
  • width / height
  • margin / padding
  • font-size / font-family / font-weight
  • line-height
  • text-align
  • background
  • border
  • overflow
  • :hover / :active

このあたりは小さな差でも見た目に出ます。

例えば inline-block が暗黙の block width を引きずると、テキストだけ伸びて背景の box が伸びない、といったバグになります。margin: 0 autoauto を CSS shorthand の段階で落としてしまうと、中央寄せが効きません。

ブラウザの layout は「だいたい flex ができればよい」ではなく、HTML/CSS のデフォルトや auto の扱いをかなり細かく持っています。WPTのテスト環境を整えて通すでもいいですが、ここは実サイトを開きながらある程度潰していきました。テスト環境はこれから整えていきます。

Scene は React renderer の副産物

描画前の中間表現として Enaga の scene graph を使っています。

これはブラウザ専用に理想から設計したものではありません。もともとは Okojo と SkiaSharp を使って React renderer を作る過程で、DOM や browser に依存しない rendering の仕組みとして作ったものです。

結果として、HTML renderer から見ると次のような利点がありました。

  • HTML 以外の UI でも同じ描画経路を使える
  • native window と offscreen texture の両方に出しやすい
  • layout 結果を SceneLayoutCommit として固定できる
  • hit test や dirty rect 計算を DOM から分離できる
  • Skia painter を HTML 専用にしなくてよい

一方で、これは browser engine の理想的な layout tree / fragment tree ではありません。後から HTML/CSS の都合を載せているので、inline formatting や fragmentation のような場所では無理が出やすいです。

ただ、個人開発でここまで持っていくにはかなり扱いやすい形でした。DOM から直接 Skia を叩くより、いったん scene commit を作るほうがデバッグもしやすいです。

SkiaSharp で native window に描く

最終的な paint は SkiaSharp です。

SceneLayoutCommit には node、layout box、text、image、background、border、scroll 情報などが入っています。painter はそれを見て SKCanvas に描きます。

描くものは単純で、

  • background
  • border
  • text
  • image
  • text input
  • select
  • scrollbar
  • shadow

などです。

Web の <canvas> に描いているのではなく、Silk.NET 側の native window / GPU surface に SkiaSharp で直接描いています。

graphics API は 1 つに固定していません。window 側には RenderGraphicsBackend があり、OpenGlVulkanMetal を選べます。SampleBrowser でも --opengl--vulkan--metal を指定できます。macOS では既定で Metal、それ以外では Vulkan を優先するようにしています。

内部的には ISkiaWindowSurface という小さい境界を置いています。上の renderer から見ると、必要なのはだいたい次の 3 つです。

SKCanvas
GRContext
Present(dirtyRects)

OpenGL の場合は、content 用の texture / framebuffer を作り、そこに Skia の SKSurface を張ります。paint は content surface に対して行い、最後に window framebuffer 側へ draw します。

Vulkan の場合は、Vulkan instance / device / swapchain / command buffer / semaphore / fence を自前で持ち、Skia の GRContext と content image をつなぎます。毎フレーム swapchain image を acquire して、dirty rect があれば content version を進め、必要な範囲を swapchain image に反映して present します。

Metal の場合は、Cocoa window に CAMetalLayer を付け、MTLDevice / command queue と Skia の Metal backend context をつなぎます。content texture に描いたものを drawable surface に draw して present します。

つまり HTML renderer は graphics API を直接知りません。HTML/CSS からできた SceneLayoutCommitSKCanvas に描くところまでは同じで、その先の「この SKCanvas は OpenGL framebuffer なのか、Vulkan image なのか、Metal drawable なのか」を window surface 側に閉じ込めています。

画像は remote image cache で解決し、読み込みが終わったら repaint を要求します。SVG は Svg.Skia の経路で rasterize します。font も Skia 側で解決しますが、CSS の font-family stack と実際の fallback の差はかなり見た目に出るので、ここもブラウザ互換では難しいところです。

input と IME

input は scene から DOM に戻す

表示だけなら一方向ですが、ブラウザとして使うには入力を戻す必要があります。

pointer move / down / up、wheel、keyboard は Silk.NET の input event から入ります。SilkWindowInputRouter が mouse / keyboard を拾い、render root が実装している IInputSink に渡します。

Silk.NET mouse / keyboard event

SilkWindowInputRouter

IInputSink

HtmlSceneFrameSource

scene layout hit test

DOM node / form state / browser runtime

pointer は scene layout 上で hit test して、対応する DOM node を引きます。cursor も同じ経路で、scene 側が PointerCursorKind.PointerText を返し、Silk.NET の standard cursor に変換します。

click の経路は次のような形です。

PointerDown

scene hit test

active DOM node を記録

PointerUp

scene hit test

同じ DOM node 上で release されたか確認

ElementClicked / LinkActivated

browser runtime に渡す

onclick / addEventListener("click") を呼ぶ

<a href> の navigation と、onclick / addEventListener の callback は別の状態として扱っています。ここを雑にすると、form control の処理を足したときに link activation が壊れる、のようなことが起きます。

text input はさらに状態を持ちます。caret、selection、IME composition、double click selection、submit などは scene 上の text input state として管理し、必要に応じて DOM / JavaScript 側の value と同期します。

text input では、DOM の value 属性だけを見ているわけではありません。ユーザーが入力中の live value、selection、caret、composition を renderer 側の state として持ち、必要なときに DOM / JavaScript runtime 側へ反映します。DOM 属性と入力中の UI state は同じではないので、ここを分けないと caret や IME composition がすぐ壊れます。

IME

IME は rendering や DOM とは別に、OS の text input と向き合う必要がある部分です。英数字だけなら Silk.NET の KeyCharTextInput に流せば動きますが、日本語入力では preedit、変換候補、確定文字、caret rectangle が必要になります。

Windows では NativeInlineIme.Windows という小さい層を作り、IMM32 の message を直接見ています。WindowsImeContext が window procedure を差し替えて、WM_IME_STARTCOMPOSITIONWM_IME_COMPOSITIONWM_IME_ENDCOMPOSITIONWM_CHAR を受けます。

流れはだいたいこうです。

Win32 / IMM32 message

WindowsImeContext

Imm32InputMethod

RenderRootTextInputClient

ITextCompositionSink / IInputSink

HtmlTextInputState

GCS_COMPSTR が来たら preedit text と cursor position を読み、SetPreeditText(...) で scene 側の composition state を更新します。GCS_RESULTSTR が来たら確定文字として CommitText(...) し、最終的には通常の TextInput と同じように input element の value に入ります。

IME window の位置も重要です。IME の候補 window が画面左上などに出るとかなり使いにくいので、render root から現在の caret rectangle を取り、IMM32 側へ渡して IME window を caret 付近へ動かしています。描画が終わるたびに OnRendered() で IME context を更新しているのはこのためです。

また、確定文字は WM_IME_COMPOSITION の result と WM_CHAR の両方から見えることがあります。そのまま両方流すと同じ文字が二重に入るので、確定文字を suppression queue に積んで、直後の WM_CHAR を無視する処理を入れています。

macOS 側にも Cocoa 用の実装があります。MacNativeWindowPlatformIntegration が Silk.NET から Cocoa の NSWindow を取り、content view に対して setMarkedText:selectedRange:replacementRange:unmarkTextinsertText:replacementRange:firstRectForCharacterRange:actualRange: を差し替えます。

Cocoa text input selector

MacImeContext

ITextCompositionSink

HtmlTextInputState

setMarkedText では marked text と selected range を scene 側の composition state に入れます。insertText では composition commit の前後だけを render root に通知し、通常の text insertion path は Cocoa / Silk.NET 側の流れを残します。firstRectForCharacterRange は render root の caret rectangle を screen coordinate に変換して返すので、macOS の候補 window も caret 付近に出せます。

つまり Windows は IMM32 message を直接処理する実装、macOS は Cocoa text input selector を hook する実装です。Linux まで同じ fidelity でできているわけではありません。ただ、ブラウザを native window に直接描くなら、IME も WebView に任せられません。text input をまともに使えるようにするには、rendering とは別に OS の text input protocol と向き合う必要があります。

できていないこと

単純な Web サイトはかなり表示できますが、もちろんブラウザとしてはまだ足りないものが多いです。

  • video / audio
  • canvas / WebGL
  • CSS animation / transition
  • 本格的な WASM の browser integration
  • Service Worker
  • IndexedDB
  • Cache API
  • 完全な DOM Event model
  • CORS / CSP / COOP / COEP
  • navigation algorithm
  • browser profile
  • persistent cookies / storage

Okojo 側に WASM 関連の実装があっても、それをブラウザとして HTML、script、fetch、streaming、origin policy、cache と統合するのは別問題です。

このあたりまでやると、単に API を足すだけではなく、browser engine としての policy layer が必要になります。

AIとの開発で効いたこと

今回の規模だと、AI agent に丸投げしても安定しません。逆に、AI が自走しやすい test と tool を用意するとかなり進みます。

特に効いたのは test262 です。JavaScript engine は仕様が広く、手元の小さな sample だけではすぐ壊れます。test262 を大量に回し、失敗を集計し、特定の feature や error を検索できるようにしておくと、AI に「この failure class を潰す」作業を渡しやすくなります。

ほかにも、V8 の bytecode と比較する tool、API surface を集計する tool、dotnet command を agent 向けに wrap する tool を作りました。dotnet command wrapper は akeit0/DnRelay として公開しています。

AI は放っておくと、後方互換性を壊さない最小修正に寄りがちです。小さな patch としては安全ですが、エンジンやブラウザのような土台では負債を温存しやすい。そのため、必要なら実装を捨てて書き直す、module boundary を保つ、test で behavior を固定する、という方向を強めに指示していました。

おわりに

最初からブラウザを作るつもりだったわけではありません。C# で JavaScript エンジンを書き、React renderer を試し、HTML/CSS も描けそうだったのでつなげていった結果、ブラウザのようなものになりました。

動画、アニメーション、WASM、storage、security policy など、まだ扱いきれていない領域は多いです。実装したものは現代のブラウザ全体から見ればごく一部です。

それでも、単純な Web サイトを読み込み、CSS を解釈し、JavaScript を実行し、native window に描画するところまでを、AI の力を借りれば個人で、しかも C# で作れてしまうのはかなり驚きでした。

JavaScript エンジンから作るのはさすがに重いですが、今後は小さな自作ブラウザや用途特化ブラウザを作る人が増えてもおかしくないと思います。

もしかすると、これからは自作ブラウザの時代かもしれません。

Discussion

ヤマサキ サフォークヤマサキ サフォーク

昔、IE製のタブブラウザが乱立していた時代があったのですが、そんなのがまた来るんですかねぇ。。。