From c2dc4d4244684aa9fcb6bb8c0d99199aa74b3de6 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 10:45:18 +0700 Subject: [PATCH 1/5] The browser is a third target, and emcc is its driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flan build --target=web produces a page, its JS and a .wasm. The two wasm targets share the word and almost nothing else, so is_wasi and is_web are separate predicates and is_wasm is their union — the union is exactly the facts about the machine, 32-bit pointers and no dlopen, which is what the refusals are about. Everything the wasi target has to find by hand is what emcc already is: no sysroot, no builtins archive, no shadow resource directory, and no __main_argc_argv shim, because emscripten's start code calls main under that name. target_flags for web is empty and the only thing checked is that emcc exists. The one fact this rests on is that emcc takes a .ll on its command line, so Emit's output needs no change. The main loop is -sASYNCIFY rather than emscripten_set_main_loop, which BUILT.md predicted. The prediction had the browser right and the cost wrong: set_main_loop wants the loop body as a callback, so every example that writes (until (rl/window-should-close?) ...) would be split by hand into an init and a tick and would stop being the native program. raylib's web platform is built for asyncify instead — WindowShouldClose on PLATFORM_WEB is an emscripten_sleep(16) that returns false — so the loop yields at a call it already makes and no example changed a character. Asyncify goes on every web link, because whether a program blocks is not a question Build can answer and a per-program flag set is a per-program cache key. A link line may now be addressed to one target — @native, @wasi, @web — and ${NAME} expands from the environment. The selection is here and not in Load, which reads the file, because Load resolves imports before a target is chosen. The object cache now keys on whichever compiler the target uses, so an emcc object and a clang one of the same source cannot collide. The refusals name the target that was asked for; --sanitize on web says the weaker truth, that emscripten ships an ASan and nothing here has ever run it. --- bin/main.ml | 11 +- lib/build.ml | 321 ++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 297 insertions(+), 35 deletions(-) diff --git a/bin/main.ml b/bin/main.ml index 8cd61ec..35d178f 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -81,7 +81,8 @@ let sanitize_flag = "--sanitize" let flags = [ no_checks_flag; dev_flag; debug_flag; sanitize_flag ] -(* [--target=wasm32-wasi], the one cross target. Unlike the flags above it +(* [--target=wasm32-wasi] and [--target=web], the two cross targets. Unlike + the flags above, a target carries a value, so it is matched by prefix and stripped from the residual arguments by the same test — otherwise [-o out --target=X] falls into the usage error. *) @@ -187,13 +188,17 @@ let () = let base = Filename.remove_extension (Filename.basename path) in (* A wasm module is not an executable and must not be named like one: the extension is what tells a runtime, and a reader, what it is. *) + (* A web build is three files — the page, its JS and the module — and + the page is the one named here: emcc derives the other two from it, + and it is the one a browser opens. *) (match target with + | Some t when Flan.Build.is_web t -> base ^ ".html" | Some t when String.starts_with ~prefix:"wasm32" t -> base ^ ".wasm" | _ -> base) | _ -> prerr_endline "usage: flan build [-o out] [--no-bounds-checks] \ - [--dev] [--debug] [--sanitize] [--target=wasm32-wasi]"; + [--dev] [--debug] [--sanitize] [--target=wasm32-wasi|web]"; exit 2 in with_errors path (fun () -> @@ -285,7 +290,7 @@ let () = prerr_endline "usage: flan (read|parse|check|emit|shim) ...\n\ \ flan build [-o out] [--no-bounds-checks] [--dev] \ - [--debug] [--sanitize] [--target=wasm32-wasi]\n\ + [--debug] [--sanitize] [--target=wasm32-wasi|web]\n\ \ flan run [args...]\n\ \ flan reload [-o out.so]\n\ \ flan dev [-s socket]"; diff --git a/lib/build.ml b/lib/build.ml index 4886c31..af575e3 100644 --- a/lib/build.ml +++ b/lib/build.ml @@ -4,12 +4,24 @@ {v flan → typed IR → .ll → clang --target={native,wasm32} v} + Three targets now, and the third takes a different driver: the browser is + built by emcc, which accepts the same .ll. See [is_web]. + Not the dev path — that one never invokes the clang driver, because the driver *is* the cost, and goes llc + ld -shared + dlopen instead. That is [shared], at the bottom of this file, measured at ~19ms. *) let clang = try Sys.getenv "FLAN_CLANG" with Not_found -> "clang" +(* The browser target's compiler. emcc is a clang driver with a sysroot, a + builtins archive, a JS runtime and an HTML shell already attached, so the + whole of [wasm_sysroot], [wasm_builtins] and [wasm_resource_dir] below — + everything the wasi target has to find by hand — is what emcc *is*. It also + accepts a .ll on its command line, which is the one thing that had to be + true for this target to exist at all: [Emit] writes IR text and nothing + else. *) +let emcc = try Sys.getenv "FLAN_EMCC" with Not_found -> "emcc" + let write path contents = let ch = open_out path in output_string ch contents; @@ -50,7 +62,10 @@ let cachedir () = d type opts = { - target : string option; (* None is the host; "wasm32-wasi" is the other *) + (* None is the host. "wasm32-wasi" is the headless one, run by a WASI + runtime; "web" is the browser one, built by emscripten. They share the + word wasm and almost nothing else — see [is_wasi] and [is_web]. *) + target : string option; opt : string; keep : bool; (* leave the .ll behind *) checks : bool; (* bounds-check [at] and [slice] *) @@ -134,11 +149,38 @@ let cflags opts = let getenv name = try Some (Sys.getenv name) with Not_found -> None -let is_wasm t = String.starts_with ~prefix:"wasm32" t +(* ── Two wasm targets, spelled apart ───────────────────────────────── + [web] is emscripten's: a browser, a GL context, a JS runtime, and a clang + whose sysroot and builtins come with it. [wasm32-wasi] is the headless one: + a WASI runtime, no GL, no browser, and a sysroot this file has to find. + "wasm32-unknown-emscripten" is accepted as a synonym for [web] because that + is the triple, and someone will write it. + + What the two share is the machine — 32-bit pointers, no dlopen — which is + exactly the set of things [is_wasm] guards: the three refusals below are + about the machine and so they apply to both. *) +let is_web t = + t = "web" || t = "emscripten" + || String.starts_with ~prefix:"wasm32-unknown-emscripten" t + || String.starts_with ~prefix:"wasm32-emscripten" t + +let is_wasi t = String.starts_with ~prefix:"wasm32" t && not (is_web t) +let is_wasm t = is_web t || is_wasi t let wasm_target opts = match opts.target with Some t when is_wasm t -> true | _ -> false +let web_target opts = + match opts.target with Some t when is_web t -> true | _ -> false + +let wasi_target opts = + match opts.target with Some t when is_wasi t -> true | _ -> false + +(* Which compiler a target is built by. This is not a flag difference: emcc is + a different program with a different driver, and the object cache key below + carries it for the same reason it carries clang's mtime. *) +let compiler opts = if web_target opts then emcc else clang + (* Where on PATH a program is, or None. *) let on_path prog = let dirs = String.split_on_char ':' (try Sys.getenv "PATH" with Not_found -> "") in @@ -256,6 +298,19 @@ let wasm_resource_dir () = let target_flags opts = match opts.target with | None -> [] + (* The browser target adds no flags to a compile at all: emcc already is the + triple, the sysroot and the builtins. What it does need is to exist, and + an emcc that is not there must be refused here — where the reason can name + it — and not at the first "command not found" from a subshell. *) + | Some t when is_web t -> + if on_path emcc = None && not (Sys.file_exists emcc) then + failwith + (Printf.sprintf + "web: no %s on PATH. The browser target is built by emscripten: \ + install an emsdk and source its emsdk_env.sh, or set FLAN_EMCC to \ + the emcc to use." + emcc); + [] | Some t when not (is_wasm t) -> [ "--target=" ^ t ] | Some t -> let sysroot = wasm_sysroot () in @@ -285,23 +340,202 @@ let wasm_main_source = "int flan_entry(int argc, char **argv) __asm__(\"main\");\n\ int __main_argc_argv(int argc, char **argv) { return flan_entry(argc, argv); }\n" +(* ── The browser: the main loop, the shell, and the link ───────────── + The mechanism, and why it is not the one the old note predicted. + + BUILT.md says a web build "drives the loop with [emscripten_set_main_loop] + instead of a [while]. That is a different [main], not a different program." + The first half is right about the browser and wrong about what it costs + here: [emscripten_set_main_loop] wants the loop body as a callback, so every + example that spells + + (until (rl/window-should-close?) ...) + + would have to be cut in half — an init and a tick — by hand, in every file, + and the resulting program would no longer be the one that runs natively. + + [-sASYNCIFY] is the other half of the same browser fact and costs none of + that. It rewrites the module so a call can suspend and resume across a + return to the event loop, and raylib's web platform is built for it: + [WindowShouldClose] on PLATFORM_WEB is an [emscripten_sleep(16)] that then + returns false (raylib 5.5, platforms/rcore_web.c). So the Flan [until] loop + yields to the browser once a frame, at the call it already makes, and not + one example changes. Same program, same [main], same source on both targets. + + What it costs is real and worth writing down: asyncify instruments the whole + module, which is roughly a doubling of code size and a measurable slowdown + on the instrumented paths. It is applied to every web build rather than to + the ones that block, because "does this program block" is not a question + this file can answer and a flag set that varies per program is a cache key + that varies per program. *) + +let web_shell_default = + {html| + + + + + flan + + + + +

+    
+    {{{ SCRIPT }}}
+  
+
+|html}
+
+(* The shell is a string here rather than a file in the tree for the same
+   reason [Runtime_src] is: it has to be present wherever the compiler is, and
+   a build that cannot find its own shell is a build that fails for a reason
+   nobody spelled. FLAN_WEB_SHELL replaces it. *)
+let web_shell_file () =
+  match getenv "FLAN_WEB_SHELL" with
+  | Some p when Sys.file_exists p -> p
+  | Some p ->
+    failwith (Printf.sprintf "web: FLAN_WEB_SHELL is %s, which does not exist" p)
+  | None ->
+    let p = Filename.concat (workdir ()) "flan-shell.html" in
+    write p web_shell_default;
+    p
+
+(* The flags the browser target adds at the link, and only at the link.
+
+   ALLOW_MEMORY_GROWTH because the default heap is 16MB and a texture is not
+   small. EXPORT_ES6=0 and the default MODULARIZE are left alone so that the
+   .js is a plain script both the shell and node can run — which is what makes
+   the test headless.
+
+   --shell-file only when the output is a page. Asking emcc for a .js and
+   handing it an HTML shell is accepted and ignored, which is the kind of
+   silence this file tries not to produce. *)
+let web_link_flags ~out =
+  [ "-sASYNCIFY"; "-sALLOW_MEMORY_GROWTH=1" ]
+  @ (if Filename.extension out = ".html" then
+       [ "--shell-file"; Filename.quote (web_shell_file ()) ]
+     else [])
+
+(* ── A [link] line may be addressed to one target ────────────────────
+   `vendor/raylib/link` names Fedora's libraylib.so.550, which exists on the
+   host and nowhere else; the browser wants a static archive built by
+   emscripten and three -s flags besides. So a line may carry a tag:
+
+       @native -l:libraylib.so.550
+       @web    ${FLAN_RAYLIB_WEB}
+
+   and an untagged line applies to every target, which is what every existing
+   `link` file is.
+
+   The selection happens *here* and not in [Load], which is where the file is
+   read, because [Load] resolves imports before a target is chosen — the same
+   program is built for both — and a package's linker arguments are carried to
+   this function as a flat list of strings. [Load] passing the lines through
+   untouched is the whole of its part in this.
+
+   ${NAME} expands from the environment. An unset one is refused by name: the
+   archive a web build needs is built once by vendor/raylib/build-web.sh and
+   lives at a path only that machine knows, and the alternative to naming it
+   here is a linker error about GLFW symbols. *)
+
+let link_tags = [ "native"; "wasi"; "web" ]
+
+let target_tag opts =
+  if web_target opts then "web"
+  else if wasi_target opts then "wasi"
+  else "native"
+
+(* ${NAME} → the environment's NAME. Nothing else is substituted: this is not a
+   shell, and a linker argument containing a $ that is not a ${ is left alone
+   rather than guessed at. *)
+let expand_vars ~where s =
+  let b = Buffer.create (String.length s) in
+  let n = String.length s in
+  let rec go i =
+    if i >= n then ()
+    else if i + 1 < n && s.[i] = '$' && s.[i + 1] = '{' then
+      match String.index_from_opt s (i + 2) '}' with
+      | None -> Buffer.add_char b s.[i]; go (i + 1)
+      | Some j ->
+        let name = String.sub s (i + 2) (j - i - 2) in
+        (match getenv name with
+         | Some v -> Buffer.add_string b v
+         | None ->
+           failwith
+             (Printf.sprintf
+                "%s: the linker argument %s wants %s, which is not set in the \
+                 environment"
+                where s name));
+        go (j + 1)
+    else (Buffer.add_char b s.[i]; go (i + 1))
+  in
+  go 0;
+  Buffer.contents b
+
+let select_lflags opts flags =
+  let want = target_tag opts in
+  List.filter_map
+    (fun f ->
+       if String.length f > 0 && f.[0] = '@' then begin
+         let tag, rest =
+           match String.index_opt f ' ' with
+           | Some i ->
+             (String.sub f 1 (i - 1),
+              String.trim (String.sub f (i + 1) (String.length f - i - 1)))
+           | None -> (String.sub f 1 (String.length f - 1), "")
+         in
+         if not (List.mem tag link_tags) then
+           failwith
+             (Printf.sprintf
+                "link: @%s is not a target — the tags are %s, and an untagged \
+                 line applies to all of them"
+                tag
+                (String.concat ", " (List.map (fun t -> "@" ^ t) link_tags)));
+         if tag = want && rest <> "" then
+           Some (expand_vars ~where:("link: @" ^ tag) rest)
+         else None
+       end
+       else Some (expand_vars ~where:"link" f))
+    flags
+
 (* What the compiler itself is, cheaply: its path, size and mtime. A clang
    upgrade changes one of those, so the key changes with it — without paying a
    [clang --version] subprocess on every build, which would cost most of what
-   the cache buys. *)
-let clang_stamp =
-  lazy
-    (let path =
-       if Filename.is_relative clang then
-         let dirs = String.split_on_char ':' (try Sys.getenv "PATH" with Not_found -> "") in
-         (try List.find (fun d -> Sys.file_exists (Filename.concat d clang))
-                dirs |> fun d -> Filename.concat d clang
-          with Not_found -> clang)
-       else clang
-     in
-     match Unix.stat path with
-     | st -> Printf.sprintf "%s:%d:%f" path st.Unix.st_size st.Unix.st_mtime
-     | exception Unix.Unix_error _ -> path)
+   the cache buys. Taken of whichever compiler the target uses, so an emcc
+   object and a clang object of the same source cannot collide. *)
+let stamp_of prog =
+  let path =
+    if Filename.is_relative prog then
+      let dirs = String.split_on_char ':' (try Sys.getenv "PATH" with Not_found -> "") in
+      (try List.find (fun d -> Sys.file_exists (Filename.concat d prog))
+             dirs |> fun d -> Filename.concat d prog
+       with Not_found -> prog)
+    else prog
+  in
+  match Unix.stat path with
+  | st -> Printf.sprintf "%s:%d:%f" path st.Unix.st_size st.Unix.st_mtime
+  | exception Unix.Unix_error _ -> path
+
+let clang_stamp = lazy (stamp_of clang)
 
 (* Compile one C translation unit to an object file, reusing a cached one when
    the source text, the compiler and the flags are all unchanged. The key has
@@ -312,11 +546,12 @@ let compile_c ~opts ?tflags ~src ~name () =
      resource directory decide which headers and which builtins an object was
      built against, so repointing either must not serve a stale .o. *)
   let tflags = match tflags with Some f -> f | None -> target_flags opts in
+  let cc = compiler opts in
   let key =
     Digest.to_hex
       (Digest.string
          (String.concat "\000"
-            [ name; src; Lazy.force clang_stamp; opts.opt;
+            [ name; src; stamp_of cc; opts.opt;
               String.concat " " (cflags opts);
               String.concat " " tflags ]))
   in
@@ -330,14 +565,14 @@ let compile_c ~opts ?tflags ~src ~name () =
     let tmp = Printf.sprintf "%s.%d.tmp" obj (Unix.getpid ()) in
     let cmd =
       String.concat " "
-        ([ Filename.quote clang; opts.opt ]
+        ([ Filename.quote cc; opts.opt ]
          @ cflags opts
          @ [ "-c" ] @ tflags
          @ [ Filename.quote c; "-o"; Filename.quote tmp ])
     in
     let code = Sys.command cmd in
     if code <> 0 then
-      failwith (Printf.sprintf "%s failed (exit %d) on %s" clang code name);
+      failwith (Printf.sprintf "%s failed (exit %d) on %s" cc code name);
     (try Unix.rename tmp obj with Unix.Unix_error _ -> ());
     (try Sys.remove c with Sys_error _ -> ())
   end;
@@ -351,10 +586,15 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) ?(pnames = [])
   (* A dev build is the REPL's, and the REPL reaches a running process through
      [-rdynamic] and [dlopen]. Neither exists on wasm32, so the combination is
      refused rather than quietly producing a module nothing can attach to. *)
+  (* Named for the target that was asked for, since two of them answer to
+     [wasm_target] now and "wasm32:" on a --target=web build reads as a
+     compiler that did not hear the question. *)
+  let tname = if web_target opts then "web" else "wasm32" in
   if wasm_target opts && opts.dev then
     failwith
-      "wasm32: --dev is native only — the reload path is dlopen, which wasm32 \
-       has no equivalent of";
+      (tname
+       ^ ": --dev is native only — the reload path is dlopen, which wasm has \
+          no equivalent of");
   (* Refused rather than emitted-and-hoped-for. The member offsets in the DWARF
      are computed for the host's layout — [ptr] 8 bytes — and wasm32's pointer
      is 4, so a slice's [len] is at byte 8 there and at byte 16 here. Emitting
@@ -363,16 +603,26 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) ?(pnames = [])
      keeps meeting at the FFI boundary. *)
   if wasm_target opts && opts.debug then
     failwith
-      "wasm32: --debug is native only — the DWARF member offsets are computed \
-       for the host's layout, and wasm32's 32-bit pointer moves every one of \
-       them";
+      (tname
+       ^ ": --debug is native only — the DWARF member offsets are computed \
+          for the host's layout, and wasm32's 32-bit pointer moves every one \
+          of them");
   (* There is no wasm32 sanitizer runtime to link against: clang accepts
      -fsanitize=address for the triple and the link fails on
      __asan_report_load4. Refused by name rather than met at the linker. *)
+  (* emscripten does ship an ASan, so the web half of this refusal is weaker
+     than the wasi half: it is untested here rather than known to be
+     impossible. Refused all the same, because a sanitizer that has never been
+     run is a sanitizer whose silence means nothing, and the sweep this project
+     runs (@sanitize) is native. *)
   if wasm_target opts && opts.sanitize then
     failwith
-      "wasm32: --sanitize is native only — there is no libclang_rt.asan for \
-       wasm32-wasi to link against";
+      (if web_target opts then
+         "web: --sanitize is native only — emscripten has its own ASan, and \
+          nothing here has ever run it; the sanitizer sweep is the native one"
+       else
+         "wasm32: --sanitize is native only — there is no libclang_rt.asan for \
+          wasm32-wasi to link against");
   (* -O0 is not a choice a debug build offers: [llvm.dbg.declare] describes an
      alloca, and at -O2 mem2reg deletes the alloca. [sanitize] deliberately
      does not do this: see [opts]. *)
@@ -399,8 +649,11 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) ?(pnames = [])
   let objs =
     cc Runtime_src.source "flan_rt.c"
     :: [ cc Runtime_src.dev_source "flan_dev.c" ]
-    (* wasi-libc's entry point, which is not [main]. See [wasm_main_source]. *)
-    @ (if wasm_target opts then [ cc wasm_main_source "flan_wasm_main.c" ] else [])
+    (* wasi-libc's entry point, which is not [main]. See [wasm_main_source].
+       Not the browser's: emscripten's start code calls [main] under that name,
+       so the .ll's @main is already the entry point and the shim would be a
+       second definition of it. *)
+    @ (if wasi_target opts then [ cc wasm_main_source "flan_wasm_main.c" ] else [])
     (* The generated half of the FFI: one translation unit holding a typedef
        per struct that crosses and a wrapper per (declare-c ...), compiled
        exactly like a package's hand-written .c. It rides on the program
@@ -414,7 +667,7 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) ?(pnames = [])
   in
   let cmd =
     String.concat " "
-      ([ Filename.quote clang; opts.opt; "-Wno-override-module" ]
+      ([ Filename.quote (compiler opts); opts.opt; "-Wno-override-module" ]
        (* -g at the link so clang does not strip, and keeps the object files'
           debug sections; the .ll carries its own. The sanitizer flags have to
           be here too — they are what pulls in libclang_rt.asan and the UBSan
@@ -424,9 +677,12 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) ?(pnames = [])
        @ cflags opts
        @ (if opts.dev then [ "-rdynamic" ] else [])
        @ tflags
+       @ (if web_target opts then web_link_flags ~out else [])
        @ [ Filename.quote ll ]
        @ List.map Filename.quote objs
-       @ lflags
+       (* A package's linker arguments, with the lines addressed to another
+          target dropped and ${VAR} expanded. See [select_lflags]. *)
+       @ select_lflags opts lflags
        (* The prelude declares sqrtf, so every link needs libm. It goes here
           and not in the leading flags: the default --as-needed drops a
           library named before the object that wants it, so at -O2 this would
@@ -480,8 +736,9 @@ let shared ?(opts = default) ~ir ~out () : timing =
   let opts = if opts.debug then { opts with opt = "-O0" } else opts in
   if wasm_target opts then
     failwith
-      "wasm32: the reload path is native only — it is llc + ld -shared + \
-       dlopen, and wasm32 has no dlopen";
+      ((if web_target opts then "web" else "wasm32")
+       ^ ": the reload path is native only — it is llc + ld -shared + dlopen, \
+          and wasm has no dlopen");
   let dir = workdir () in
   let base = Filename.remove_extension (Filename.basename out) in
   let ll = Filename.concat dir (base ^ ".ll") in

From f51559030ad13d34ea62078a9dcacddf9cb15d05 Mon Sep 17 00:00:00 2001
From: Joseph Ferano 
Date: Sat, 12 Sep 2026 10:45:27 +0700
Subject: [PATCH 2/5] raylib for the browser is built rather than installed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

No emscripten port provides raylib — emcc --show-ports offers contrib.glfw3
and nothing else nearby — so build-web.sh clones raylib at the 5.5 tag and
compiles its seven modules with -DPLATFORM_WEB -DGRAPHICS_API_OPENGL_ES2 into
one archive under vendor/raylib/web, which is gitignored along with the
checkout it came from.

5.5 because that is the tag whose .so.550 the host links. raylib.flan carries
raylib's struct layouts and enum values, and two targets built from different
raylibs would disagree about them without saying so.

rglfw.c is not among the modules: the web platform uses emscripten's own GLFW
port, which is why link carries @web -sUSE_GLFW=3. No headers are installed,
for the same reason the host build needs none — the generated shim declares
the prototypes it uses.

link now names the host library under @native and the archive under @web,
through ${FLAN_RAYLIB_WEB}, so a web build with the variable unset is refused
with the name of the variable rather than a page of undefined GLFW symbols.
The one thing this costs: a wasi build that reaches raylib now fails on
undefined symbols instead of on the missing -l:libraylib.so.550.
---
 .gitignore                 |  5 +++
 vendor/raylib/build-web.sh | 76 ++++++++++++++++++++++++++++++++++++++
 vendor/raylib/link         | 33 ++++++++++++++++-
 3 files changed, 112 insertions(+), 2 deletions(-)
 create mode 100644 vendor/raylib/build-web.sh

diff --git a/.gitignore b/.gitignore
index 67b9111..634e8d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,3 +45,8 @@ old-ocaml/
 .claude/
 probe
 probe.c
+
+# raylib built for the browser: a 1.6MB archive and the pinned checkout it
+# came from. vendor/raylib/build-web.sh makes both, and the path is named to a
+# build through FLAN_RAYLIB_WEB, not committed.
+vendor/raylib/web/
diff --git a/vendor/raylib/build-web.sh b/vendor/raylib/build-web.sh
new file mode 100644
index 0000000..1cc7388
--- /dev/null
+++ b/vendor/raylib/build-web.sh
@@ -0,0 +1,76 @@
+#!/bin/sh
+# raylib, built for the browser.
+#
+# The host half of this package needs no build: Fedora ships libraylib.so.550
+# and `link` names it. The browser has no such thing, so the archive has to be
+# made here, once, out of raylib's own sources with emscripten's clang.
+#
+# Pinned to the tag whose shared library the host links — 5.5 against
+# libraylib.so.550 — because `raylib.flan` carries struct layouts and enum
+# values that are raylib's, not ours, and a build where the two targets are
+# different raylibs would disagree about them silently.
+#
+# The output is a plain static archive plus nothing else: no headers are
+# installed, because the generated FFI shim declares the prototypes it uses
+# (see BUILT.md, "No raylib headers are needed").
+#
+#   sh vendor/raylib/build-web.sh
+#
+# It prints the line to export. `vendor/raylib/link` points at the archive
+# through ${FLAN_RAYLIB_WEB}, and a web build that names raylib with the
+# variable unset is refused by name rather than met at the linker.
+#
+# Environment:
+#   FLAN_RAYLIB_TAG      the raylib tag to build (default 5.5)
+#   FLAN_RAYLIB_WEB_DIR  where to put it (default vendor/raylib/web)
+#   FLAN_RAYLIB_SRC      an existing raylib checkout to build instead of cloning
+set -eu
+
+tag=${FLAN_RAYLIB_TAG:-5.5}
+here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+out=${FLAN_RAYLIB_WEB_DIR:-$here/web}
+archive=$out/libraylib-$tag.a
+
+command -v emcc > /dev/null 2>&1 || {
+  echo "build-web.sh: no emcc on PATH. Source an emsdk's emsdk_env.sh." >&2
+  exit 1
+}
+
+if [ -f "$archive" ]; then
+  echo "already built: $archive" >&2
+else
+  mkdir -p "$out"
+  if [ -n "${FLAN_RAYLIB_SRC:-}" ]; then
+    src=$FLAN_RAYLIB_SRC
+  else
+    src=$out/raylib-$tag
+    [ -d "$src" ] || git clone --depth 1 --branch "$tag" \
+      https://github.com/raysan5/raylib "$src"
+  fi
+
+  objs=$out/obj-$tag
+  rm -rf "$objs"
+  mkdir -p "$objs"
+
+  # GRAPHICS_API_OPENGL_ES2 is what WebGL is. PLATFORM_WEB makes rcore.c
+  # include platforms/rcore_web.c, whose WindowShouldClose() is an
+  # emscripten_sleep that returns false — see BUILT.md on why that is the whole
+  # reason a Flan `until` loop needs no rewriting for the browser.
+  #
+  # rglfw.c is not in the list: the web platform uses emscripten's own GLFW
+  # (-sUSE_GLFW=3, in `link`), not a compiled-in one.
+  for m in rcore rshapes rtextures rtext rmodels raudio utils; do
+    [ -f "$src/src/$m.c" ] || continue
+    echo "  emcc $m.c" >&2
+    (cd "$src/src" && emcc -c -O2 -std=gnu99 \
+       -DPLATFORM_WEB -DGRAPHICS_API_OPENGL_ES2 \
+       -I. -Iexternal/glfw/include \
+       "$m.c" -o "$objs/$m.o")
+  done
+
+  emar rcs "$archive" "$objs"/*.o
+  rm -rf "$objs"
+fi
+
+echo
+echo "export FLAN_RAYLIB_WEB=$archive"
diff --git a/vendor/raylib/link b/vendor/raylib/link
index 21fde4d..b8317b5 100644
--- a/vendor/raylib/link
+++ b/vendor/raylib/link
@@ -1,2 +1,31 @@
--l:libraylib.so.550
--lm
+# Extra linker arguments for this package, one per line. A line may be
+# addressed to one target — @native, @wasi, @web — and an untagged line
+# applies to all of them. ${NAME} expands from the environment. The selection
+# and the expansion happen in Build, which is the only place that knows which
+# target is being built; Load reads these lines and passes them through.
+
+# The host. Fedora's package installs the versioned soname and no unversioned
+# symlink, so -lraylib finds nothing and the file has to be named.
+@native -l:libraylib.so.550
+@native -lm
+
+# The browser. No shared library exists for wasm, so this is a static archive
+# built out of raylib's own sources by vendor/raylib/build-web.sh, pinned to
+# the 5.5 tag that matches the host's .so.550 — raylib.flan carries raylib's
+# struct layouts and enum values, and two targets built from different raylibs
+# would disagree about them without saying so.
+#
+# FLAN_RAYLIB_WEB is where that archive is. Unset, a web build that reaches
+# raylib is refused by name here rather than met as a page of undefined GLFW
+# symbols; build-web.sh prints the line to export.
+@web ${FLAN_RAYLIB_WEB}
+
+# raylib's web platform is GLFW on emscripten's own port, not the rglfw.c it
+# compiles in natively, and WebGL is GLES2. GL_ENABLE_GET_PROC_ADDRESS because
+# rlgl asks for extension pointers by name.
+@web -sUSE_GLFW=3
+@web -sGL_ENABLE_GET_PROC_ADDRESS
+
+# -sASYNCIFY is not here: Build adds it to every web link, because the reason
+# for it is the browser's event loop and not raylib. See Build's comment on the
+# main loop.

From 8d048123ca2384b4503c72306b04a645f2311afe Mon Sep 17 00:00:00 2001
From: Joseph Ferano 
Date: Sat, 12 Sep 2026 10:45:42 +0700
Subject: [PATCH 3/5] What a headless test can honestly say about a page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

test_web.ml never opens a browser and never will. What it asserts is the shape
a browser needs — three files, a module that starts with the wasm magic, a
page that references its own JS and carries the canvas — plus the one
execution available without a DOM: node runs the emitted JS and gets "ok".

For raylib it builds core-basic-window.flan unchanged, which is the claim, and
then reads the module for the two things that would be false if the mechanism
were wrong: an asyncify_start_unwind export, and a glViewport import that can
only have come from raylib's web platform. Import and export names are plain
strings in the binary, so this needs no wasm reader.

Both halves probe rather than assume, the way the wasm32 case does: emscripten
may not be installed and the raylib archive is not in the tree, and a missing
piece is a skip with the reason.

The four refusals are asserted by name — --dev, --debug, --sanitize,
Build.shared, and flan run --target=web from the CLI — because "it falls out
of the existing predicate" is the kind of thing that stops being true quietly.
---
 test/dune        |  17 ++++
 test/test_web.ml | 219 +++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 236 insertions(+)
 create mode 100644 test/test_web.ml

diff --git a/test/dune b/test/dune
index 4efb846..8fa2e3b 100644
--- a/test/dune
+++ b/test/dune
@@ -33,6 +33,23 @@
   ; wasmer is installed.
   (file wasm-run.mjs)))
 
+; The web target, in its own stanza rather than in the table above because it
+; is the one case whose toolchain is a separate install: emscripten, and a
+; raylib archive built by vendor/raylib/build-web.sh. It probes for both and
+; skips with the reason, so it is green on a machine that has neither.
+(test
+ (name test_web)
+ (modules test_web)
+ (libraries flan unix)
+ (deps
+  (glob_files programs/*.flan)
+  ; The raylib bindings and the ported example the raylib case builds. The
+  ; example imports examples/digits.flan, so the directory comes whole.
+  (glob_files %{workspace_root}/vendor/raylib/*)
+  (glob_files %{workspace_root}/examples/*)
+  ; flan run --target=web is refused by the CLI, so the CLI has to be here.
+  (file %{workspace_root}/bin/main.exe)))
+
 ; The corpus a second time under ASan and UBSan. Its own alias and not part of
 ; `dune test`: a sanitized build is a statically linked 1.8MB binary that takes
 ; tens of seconds to produce, so the sweep is minutes against the existing
diff --git a/test/test_web.ml b/test/test_web.ml
new file mode 100644
index 0000000..fd97a72
--- /dev/null
+++ b/test/test_web.ml
@@ -0,0 +1,219 @@
+(* The web target: a Flan program built for the browser by emscripten.
+
+   Headless, on purpose and permanently. Nothing here drives a browser; what a
+   test can honestly say about a page it never opens is that the artifact is
+   the shape a browser needs — three files, a wasm that is a wasm, a page that
+   loads the JS beside it — and that the module has the two things the target
+   is *for*: asyncify, so a Flan `until` loop can yield to the event loop, and
+   raylib's GL imports, so the loop has something to draw with.
+
+   Everything is probed rather than assumed, the same way the wasm32 case in
+   test_acceptance is: emscripten may not be installed, and the raylib archive
+   is built once by vendor/raylib/build-web.sh and is not in the tree. A missing
+   piece is a skip with the reason, never a red test. *)
+
+open Flan
+
+let failures = ref 0
+
+let scratch = Filename.get_temp_dir_name ()
+
+let fail fmt = Printf.ksprintf (fun s -> incr failures; print_endline ("FAIL  " ^ s)) fmt
+
+let read path =
+  let ch = open_in_bin path in
+  let n = in_channel_length ch in
+  let s = really_input_string ch n in
+  close_in ch; s
+
+let contains hay needle =
+  let n = String.length needle and h = String.length hay in
+  let rec go i = i + n <= h && (String.sub hay i n = needle || go (i + 1)) in
+  go 0
+
+let have prog = Sys.command (Printf.sprintf "command -v %s > /dev/null 2>&1" prog) = 0
+
+(* One web build, through [Load] and [Reach] exactly as [flan build] does it,
+   so a package's per-target link lines are selected here too. *)
+let web_build ?(opt = "-O2") path out =
+  let l = Load.program ~file:path (Parse.program (Reader.read_file path)) in
+  let p = Check.program l.Load.decls in
+  let p, csrcs, lflags = Reach.link l p in
+  ignore
+    (Build.executable ~opts:{ Build.default with opt; target = Some "web" }
+       ~csrcs ~lflags p ~out)
+
+(* emcc derives the JS and the module from the page's name. *)
+let parts html =
+  let base = Filename.remove_extension html in
+  (html, base ^ ".js", base ^ ".wasm")
+
+let cleanup html =
+  let a, b, c = parts html in
+  List.iter (fun f -> try Sys.remove f with Sys_error _ -> ()) [ a; b; c ]
+
+(* The archive vendor/raylib/build-web.sh makes. FLAN_RAYLIB_WEB is how a
+   `link` line names it; the two relative paths are where the script puts it by
+   default, seen from a test run out of _build. Found here and put back into
+   the environment, so that a developer who has built it once does not also
+   have to remember to export it before running the suite. *)
+let raylib_web () =
+  let cands =
+    (match Sys.getenv_opt "FLAN_RAYLIB_WEB" with Some s -> [ s ] | None -> [])
+    @ [ "../../../vendor/raylib/web/libraylib-5.5.a";
+        "../vendor/raylib/web/libraylib-5.5.a" ]
+  in
+  match List.find_opt Sys.file_exists cands with
+  | Some p ->
+    let p = try Unix.realpath p with Unix.Unix_error _ -> p in
+    Unix.putenv "FLAN_RAYLIB_WEB" p;
+    Some p
+  | None -> None
+
+let () =
+  (* ── The probe ─────────────────────────────────────────────────────
+     The smallest program there is, built for the browser. If emscripten is
+     absent this is where it says so, and nothing below runs. *)
+  let probe = Filename.concat scratch "flan-web-probe.html" in
+  let outcome =
+    match web_build "programs/unit-main.flan" probe with
+    | () -> Ok ()
+    | exception Failure m -> Error m
+  in
+  (match outcome with
+   | Error why -> Printf.printf "web: skipping the web target (%s)\n" why
+   | Ok () ->
+     let html, js, wasm = parts probe in
+
+     (* Three files, because that is what "openable" means: a page, the JS that
+        instantiates the module, and the module. *)
+     List.iter
+       (fun (what, f) ->
+          if not (Sys.file_exists f) then fail "web build made no %s (%s)" what f)
+       [ ("page", html); ("JS", js); ("module", wasm) ];
+
+     (* A wasm is a wasm. The four bytes are the whole format's claim about
+        itself, and an emcc that produced JS and a stub would pass every other
+        check here. *)
+     let bytes = read wasm in
+     if String.length bytes < 8 || String.sub bytes 0 4 <> "\000asm" then
+       fail "the module does not start with the wasm magic";
+
+     (* The page has to load the module's JS, or it is a page about nothing.
+        emcc minifies the shell, so this asks for the reference and not for any
+        particular spelling of the tag. *)
+     let page = read html in
+     let base = Filename.basename (Filename.remove_extension probe) in
+     if not (contains page (base ^ ".js")) then
+       fail "the page does not reference %s.js" base;
+     (* And it has to be *our* shell: the canvas raylib draws into, and the
+        Module.print that puts stdout on the page. *)
+     if not (contains page "canvas") then fail "the page has no canvas";
+
+     (* The one execution this test does. node runs the emitted JS without a
+        DOM, which is enough for a program that only prints — and it is the
+        same "does it actually run" the wasm32 case insists on. *)
+     if not (have "node") then
+       print_endline "web: skipping the run (no node)"
+     else begin
+       let out = Filename.concat scratch "flan-web-probe.out" in
+       let code =
+         Sys.command
+           (Printf.sprintf "node %s > %s 2>&1" (Filename.quote js)
+              (Filename.quote out))
+       in
+       let text = In_channel.with_open_bin out In_channel.input_all in
+       (try Sys.remove out with Sys_error _ -> ());
+       if code <> 0 || text <> "ok\n" then
+         fail "node could not run the web build: %S (exit %d)" text code
+     end;
+     cleanup probe;
+
+     (* ── raylib in the browser ────────────────────────────────────────
+        The claim BUILT.md left open. core-basic-window.flan is built for the
+        web unchanged — no edit to its `until` loop, which is the whole point
+        of choosing asyncify over emscripten_set_main_loop — and the module is
+        then read for the two things that prove the claim rather than assert
+        it: the asyncify export, and a GL import that can only have come from
+        raylib's web platform.
+
+        Import and export names are plain strings in a wasm's name sections,
+        which is why this needs no wasm reader. *)
+     (match raylib_web () with
+      | None ->
+        print_endline
+          "web: skipping the raylib case (no libraylib-5.5.a for the browser; \
+           run sh vendor/raylib/build-web.sh)"
+      | Some _ ->
+        let out = Filename.concat scratch "flan-web-window.html" in
+        (match web_build "../examples/core-basic-window.flan" out with
+         | exception Failure m -> fail "raylib for the browser: %s" m
+         | () ->
+           let _, _, wasm = parts out in
+           let bytes = read wasm in
+           if not (contains bytes "asyncify_start_unwind") then
+             fail
+               "no asyncify in the module — the `until` loop would block the \
+                browser";
+           if not (contains bytes "glViewport") then
+             fail "no GL imports in the module — raylib did not link";
+           cleanup out)));
+
+  (* ── Refused by name ───────────────────────────────────────────────
+     The web target inherits every refusal wasm32 has, and for the same
+     reasons: the reload path is dlopen, the DWARF offsets are the host's, and
+     the sanitizer sweep is native. These are asserted because "it falls out of
+     the existing predicate" is exactly the kind of thing that stops being true
+     silently. No emscripten is needed — each is refused before any compiler
+     runs. *)
+  let refused what f =
+    match f () with
+    | () -> fail "%s was accepted" what
+    | exception Failure m ->
+      if not (contains m "web") then
+        fail "%s was refused, but the reason does not name the target: %S" what m
+  in
+  let unit_main () =
+    let path = "programs/unit-main.flan" in
+    let l = Load.program ~file:path (Parse.program (Reader.read_file path)) in
+    Check.program l.Load.decls
+  in
+  refused "--dev --target=web" (fun () ->
+    ignore
+      (Build.executable ~opts:{ Build.default with target = Some "web"; dev = true }
+         (unit_main ()) ~out:(Filename.concat scratch "flan-web-dev.html")));
+  refused "--debug --target=web" (fun () ->
+    ignore
+      (Build.executable
+         ~opts:{ Build.default with target = Some "web"; debug = true }
+         (unit_main ()) ~out:(Filename.concat scratch "flan-web-dbg.html")));
+  refused "--sanitize --target=web" (fun () ->
+    ignore
+      (Build.executable
+         ~opts:{ Build.default with target = Some "web"; sanitize = true }
+         (unit_main ()) ~out:(Filename.concat scratch "flan-web-san.html")));
+  refused "Build.shared --target=web" (fun () ->
+    ignore
+      (Build.shared ~opts:{ Build.default with target = Some "web" } ~ir:""
+         ~out:(Filename.concat scratch "flan-web.so") ()));
+
+  (* [flan run --target=web] is refused by the CLI rather than by [Build]: a
+     page is not something this host execs, and picking a browser for it is not
+     a decision that command has any business making. *)
+  let out = Filename.concat scratch "flan-web-run.out" in
+  let code =
+    Sys.command
+      (Printf.sprintf
+         "../bin/main.exe run programs/unit-main.flan --target=web > %s 2>&1"
+         (Filename.quote out))
+  in
+  let text = In_channel.with_open_bin out In_channel.input_all in
+  (try Sys.remove out with Sys_error _ -> ());
+  if code = 0 || not (contains text "--target is refused") then
+    fail "flan run --target=web was not refused: %S (exit %d)" text code;
+
+  if !failures = 0 then print_endline "web: ok"
+  else begin
+    Printf.printf "web: %d failure(s)\n" !failures;
+    exit 1
+  end

From 3e15328acd4ea07749f2e991570609cfb8cdbadc Mon Sep 17 00:00:00 2001
From: Joseph Ferano 
Date: Sat, 12 Sep 2026 10:45:42 +0700
Subject: [PATCH 4/5] Where the web target stops, including the one that is a
 missing include

BUILT.md gains the section on the third target and corrects the claim it
already carried: emscripten_set_main_loop had the browser fact right and drew
the wrong conclusion, because asyncify answers the same fact without cutting
main in half.

NEXT.md gets the four holes. sand.flan has no web build, and the proximate
cause is that vendor/agent/flan_agent.c:426 uses struct timeval without
pulling in sys/time.h, which glibc gives it transitively and emscripten does
not; sand's main calls agent/start unconditionally so Reach cannot prune it.
Beneath the include is the decision worth making rather than patching around:
the agent is a socket server and the browser has no sockets, so the honest fix
is to refuse vendor:agent on a web target the way --dev is refused.

Assets are two questions and only the easy one is about emscripten.
--embed-file is a linker argument and so already expressible as an @web line.
The hard one is that the file doing (rl/load-texture "brush.png") is
structurally the one file that cannot say so: Load hands out lflags only for a
directory package, and main is not exported, so a program can never be one. No
flag was invented for it.

And nothing has been opened in a browser, asyncify's cost is quoted rather
than measured, and audio and threads on web are untried.
---
 BUILT.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++---
 NEXT.md  | 38 +++++++++++++++++++++++++++++
 2 files changed, 108 insertions(+), 3 deletions(-)

diff --git a/BUILT.md b/BUILT.md
index 1fe5847..d81170e 100644
--- a/BUILT.md
+++ b/BUILT.md
@@ -242,7 +242,9 @@ Two claims that got run together in an earlier note, for the record:
 - *raylib does not work on wasm* — false. It works through emscripten. What is true is that it does not work on the
 **wasi** path, which is what the headless table targets, and which has no GL and no browser.
 - *a game loop cannot be expressed on wasm* — false. The browser cannot be blocked, so a web build drives the loop with
-`emscripten_set_main_loop` instead of a `while`. That is a different `main`, not a different program.
+`emscripten_set_main_loop` instead of a `while`. ~~That is a different `main`, not a different program.~~ **The premise
+held and the conclusion did not.** It is the same `main` and the same program: `-sASYNCIFY` answers the same browser
+fact without cutting anything in half. See "The browser is the third target" below.
 
 **Three edits were made to sand.flan's own text** when it was ported, and they are language decisions rather than fixes:
 
@@ -1221,5 +1223,70 @@ an `ExperimentalWarning` to stderr on every run and the harness compares combine
 one (same reason), and `flan run --target=` (a `.wasm` is not something this host execs — build it and point a runtime
 at it).
 
-Still open: raylib on wasm, which plan.org wants through emscripten and its own sysroot. wasi-sdk is right for the
-headless table; it is not necessarily right for the eventual game build.
+~~Still open: raylib on wasm, which plan.org wants through emscripten and its own sysroot.~~ **Done — and it is a third
+target, not a mode of this one.** wasi-sdk is right for the headless table and was never going to be right for the game
+build. See the next section.
+
+## The browser is the third target
+
+`flan build --target=web` produces a page, its JS and a `.wasm`, and a raylib example opens in a browser from source
+that was not touched. The two wasm targets share the word and almost nothing else, so `is_wasi` and `is_web` are
+separate predicates and `is_wasm` is their union — the union is exactly the set of facts about the *machine* (32-bit
+pointers, no `dlopen`), which is what the refusals are about, and nothing else is shared.
+
+**The compiler is `emcc`, not `clang`, and that is the whole of the sysroot story.** Everything the wasi target has to
+find by hand — a sysroot, a builtins archive, a shadow resource directory, the `__main_argc_argv` shim — is what emcc
+*is*. `target_flags` for `web` is the empty list; the only thing checked is that emcc exists, refused by name where the
+reason can say so. The one fact that had to be true for any of this: **emcc takes a `.ll` on its command line**, which
+it does, so `Emit`'s output needs no change and the IR stays target-independent. The object cache keys on the compiler
+binary's path, size and mtime as it always did — now of *whichever* compiler the target uses, so an emcc `flan_rt.o`
+and a clang one cannot collide.
+
+**The main loop: `-sASYNCIFY`, not `emscripten_set_main_loop`.** The older note above had the browser fact right —
+it cannot be blocked — and drew the wrong conclusion from it. `emscripten_set_main_loop` wants the loop body as a
+callback, so every one of the eleven examples that writes
+
+```lisp
+(until (rl/window-should-close?) ...)
+```
+
+would have to be split by hand into an init and a tick, and the web program would stop being the native program.
+Asyncify rewrites the module so a call can suspend across a return to the event loop, and raylib's web platform is
+built for precisely that: `WindowShouldClose()` on `PLATFORM_WEB` is an `emscripten_sleep(16)` that then returns false
+(raylib 5.5, `platforms/rcore_web.c`, read rather than assumed). So the loop yields once a frame at a call it already
+makes, and **no example changed a character**. The price is real and is paid by every web build: asyncify instruments
+the whole module, roughly doubling code size. It is not applied per-program because "does this program block" is not a
+question `Build` can answer, and a flag set that varies per program is a cache key that varies per program.
+
+**`link` lines can be addressed to a target.** `vendor/raylib/link` named `libraylib.so.550`, which exists on the host
+and nowhere else. A line may now carry `@native`, `@wasi` or `@web`, an untagged line applies everywhere — which is
+what every existing `link` file already is — and `${NAME}` expands from the environment. The selection happens in
+`Build` and not in `Load`, which is where the file is read, because **`Load` resolves imports before a target is
+chosen**: the same program is built for both, and a package's linker arguments arrive here as a flat list of strings.
+`Load`'s part in this is to pass the lines through untouched, which it already did.
+
+**raylib for the browser is built, not installed.** No emscripten port provides it (`emcc --show-ports`: there is
+`contrib.glfw3` and no raylib), so `vendor/raylib/build-web.sh` clones raylib at the **5.5** tag — the one whose
+`.so.550` the host links, because `raylib.flan` carries raylib's struct layouts and enum values and two targets built
+from different raylibs would disagree about them in silence — and compiles the seven modules with
+`-DPLATFORM_WEB -DGRAPHICS_API_OPENGL_ES2` into one archive under `vendor/raylib/web/` (gitignored). `rglfw.c` is not
+among them: the web platform uses emscripten's own GLFW port, which is why `link` carries `@web -sUSE_GLFW=3`. No
+headers are installed, for the reason the host build needs none — the generated shim declares its own prototypes.
+
+**The HTML shell is a string in `Build`, not a file in the tree**, for the same reason `Runtime_src` is: it has to be
+wherever the compiler is, and a build that cannot find its own shell fails for a reason nobody spelled. It is a canvas
+and a `Module.print` that puts stdout on the page; `FLAN_WEB_SHELL` replaces it. `--shell-file` is passed only when the
+output is a `.html`, because emcc accepts and ignores it otherwise.
+
+**Refused by name, inherited whole:** `--dev`, `--debug`, `Build.shared` and `flan run --target=` are refused for
+`web` exactly as for `wasm32`, each naming `web` rather than `wasm32` in the message. `--sanitize` is refused too, but
+the web half of that refusal is weaker than the wasi half and says so: emscripten *does* ship an ASan, and nothing here
+has ever run it. A sanitizer that has never been run is one whose silence means nothing.
+
+**What the test can honestly check.** `test/test_web.ml` is headless and permanently so. It probes — emscripten may not
+be installed, and the raylib archive is not in the tree — and skips with the reason rather than going red. What it
+asserts: the three files exist, the module starts with `\0asm`, the page references its own JS and carries the canvas,
+and node runs the emitted JS and gets `ok`. For raylib it builds `core-basic-window.flan` unchanged and then reads the
+module for the two things that would be false if the mechanism were wrong: an `asyncify_start_unwind` export, and a
+`glViewport` import that can only have come from raylib's web platform. Import and export names are plain strings in
+the binary, so this needs no wasm reader.
diff --git a/NEXT.md b/NEXT.md
index 4f2b851..565cfd3 100644
--- a/NEXT.md
+++ b/NEXT.md
@@ -295,6 +295,44 @@ crosses as a parameter — a C function that returns one returns something Flan
 the shape the language already has. Three constructs unexercised anywhere else in the repo worked first try: a fixed
 array with a struct element, a 2-D struct array, and `[N string]` as both `defconst` and mutable `defvar`.
 
+### The web target: what it does not reach yet
+
+`flan build --target=web` works, a raylib example builds unchanged and `test/test_web.ml` is green — see BUILT.md,
+"The browser is the third target", for the mechanism and why asyncify rather than `emscripten_set_main_loop`. Four
+things it does not cover.
+
+**1. `sand.flan` has no web build, and the cause is one missing `#include`.** `vendor/agent/flan_agent.c` does not
+compile under emcc: *variable has incomplete type 'struct timeval'* at line 426, because emscripten's headers do not
+pull `` in transitively the way glibc's do. `sand.flan`'s `main` calls `(agent/start ...)`
+unconditionally, so `Reach` cannot prune the package, so the flagship program stops at that error — even without
+`--dev`. Beneath the include is a structural fact worth deciding rather than patching around: **the agent is a socket
+server and the browser has no sockets**, which is the same family as the `--dev` refusal. So the two fixes are not
+equivalent — add the include and the agent compiles into a web build that can never accept a connection, or refuse
+`vendor:agent` by name on a web target the way `--dev` is refused. The second is the honest one. Neither was taken
+here: `vendor/agent/` belonged to another lane this session.
+
+**2. Assets are two questions and only one of them is about emscripten.** `sand.flan` does
+`(rl/load-texture "brush.png")` against a bare relative path.
+
+- The easy half: a bare relative path has no meaning on a target with no filesystem. emscripten's answer is
+  `--embed-file` or `--preload-file` into MEMFS, and both are *linker arguments*, so they are already expressible as an
+  `@web` line in a package's `link` file. No new mechanism is needed for a package.
+- The hard half, and the actual design question: **the file that needs the asset is structurally the one file that
+  cannot declare it.** `Load` hands out `lflags` only for a directory package (`one_file` → `[]`), and `main` is not
+  exported, so a program can never be a package. The program doing the `load-texture` therefore has no link channel at
+  all. Answering this means either giving a single-file program a way to carry build arguments, or making assets their
+  own declaration rather than a linker flag. No flag was invented for it here.
+
+**3. Nothing has been opened in a browser.** The test is headless and permanently so: it asserts the artifact's shape,
+the `asyncify_start_unwind` export and the `glViewport` import, and that node runs the emitted JS. Whether the canvas
+actually paints is unverified by anything in CI, and a human should look once.
+
+**4. Unmeasured and untested.** Asyncify's cost is quoted from emscripten's documentation (roughly a doubling of code
+size) and not measured here, and no frame time on web has been taken at all. raylib's audio and any use of threads on
+the web target are untried. And a **wasi** build that reaches raylib now fails on undefined symbols rather than on a
+missing `-l:libraylib.so.550`, because that line is tagged `@native` — the same error one step later, and a worse
+message.
+
 ### `break`, and why it was not built
 
 Settled, so the next attempt is cheap rather than a rediscovery:

From a649a42faa26377b4882bf433ee4b6840fb1b83c Mon Sep 17 00:00:00 2001
From: Joseph Ferano 
Date: Sat, 12 Sep 2026 10:46:50 +0700
Subject: [PATCH 5/5] A refusal test that passes for the wrong reason is not a
 test
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Each of the four cases asserted only that the message named the target. Every
one of those paths meets "web: no emcc on PATH" first on a machine with no
emscripten, which also names the target — so on exactly the machine where none
of the refusals ran, all four would have reported that they did. Each case now
names the phrase it expects.
---
 test/test_web.ml | 24 +++++++++++++++++-------
 1 file changed, 17 insertions(+), 7 deletions(-)

diff --git a/test/test_web.ml b/test/test_web.ml
index fd97a72..c1dea12 100644
--- a/test/test_web.ml
+++ b/test/test_web.ml
@@ -56,7 +56,9 @@ let cleanup html =
    `link` line names it; the two relative paths are where the script puts it by
    default, seen from a test run out of _build. Found here and put back into
    the environment, so that a developer who has built it once does not also
-   have to remember to export it before running the suite. *)
+   have to remember to export it before running the suite. That putenv outlives
+   this call, so the raylib case being the last web build in the process is
+   load-bearing: anything built after it would pick the archive up silently. *)
 let raylib_web () =
   let cands =
     (match Sys.getenv_opt "FLAN_RAYLIB_WEB" with Some s -> [ s ] | None -> [])
@@ -165,34 +167,42 @@ let () =
      the sanitizer sweep is native. These are asserted because "it falls out of
      the existing predicate" is exactly the kind of thing that stops being true
      silently. No emscripten is needed — each is refused before any compiler
-     runs. *)
-  let refused what f =
+     runs.
+
+     Each case names the phrase it expects and not merely the word "web". On a
+     machine with no emscripten the *first* thing every one of these paths
+     meets is "web: no emcc on PATH", which contains the word and is a
+     different failure entirely — so a looser check would report all four as
+     refused on exactly the machine where none of them ran. *)
+  let refused what why f =
     match f () with
     | () -> fail "%s was accepted" what
     | exception Failure m ->
       if not (contains m "web") then
         fail "%s was refused, but the reason does not name the target: %S" what m
+      else if not (contains m why) then
+        fail "%s was refused for the wrong reason: wanted %S, said %S" what why m
   in
   let unit_main () =
     let path = "programs/unit-main.flan" in
     let l = Load.program ~file:path (Parse.program (Reader.read_file path)) in
     Check.program l.Load.decls
   in
-  refused "--dev --target=web" (fun () ->
+  refused "--dev --target=web" "--dev is native only" (fun () ->
     ignore
       (Build.executable ~opts:{ Build.default with target = Some "web"; dev = true }
          (unit_main ()) ~out:(Filename.concat scratch "flan-web-dev.html")));
-  refused "--debug --target=web" (fun () ->
+  refused "--debug --target=web" "--debug is native only" (fun () ->
     ignore
       (Build.executable
          ~opts:{ Build.default with target = Some "web"; debug = true }
          (unit_main ()) ~out:(Filename.concat scratch "flan-web-dbg.html")));
-  refused "--sanitize --target=web" (fun () ->
+  refused "--sanitize --target=web" "--sanitize is native only" (fun () ->
     ignore
       (Build.executable
          ~opts:{ Build.default with target = Some "web"; sanitize = true }
          (unit_main ()) ~out:(Filename.concat scratch "flan-web-san.html")));
-  refused "Build.shared --target=web" (fun () ->
+  refused "Build.shared --target=web" "reload path is native only" (fun () ->
     ignore
       (Build.shared ~opts:{ Build.default with target = Some "web" } ~ir:""
          ~out:(Filename.concat scratch "flan-web.so") ()));